Skip to content

Go apps

github.com/masksrb/masks/go/web signs people in to a Go web app against a masks server. It covers what Masks::Rails covers in a Rails app. It is plain net/http, so it works with the standard ServeMux, chi, gorilla/mux, and any router that takes an http.Handler. It needs Go 1.25 or later.

Terminal window
go get github.com/masksrb/masks/go
Package
github.com/masksrb/masks/go The client: discovery, the handshake, the code flow, tokens, DPoP, introspection, token exchange, delegations, enrollment, and checking tokens.
github.com/masksrb/masks/go/web The web integration built on it: routes under /auth, a session, the signed-in actor, and middleware.
github.com/masksrb/masks/go/web/redisstore A store in Redis.
app, err := web.New(web.Config{
Issuer: os.Getenv("MASKS_ISSUER"),
Resource: "https://app.example.com",
Name: "Catalog",
Scope: []string{"openid", "profile", "email", "offline_access"},
Secret: []byte(os.Getenv("SESSION_SECRET")),
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/auth/", app.Handler())
mux.Handle("/settings", app.Require()(settings))
  1. Set MASKS_ISSUER to the tenant you sign in against, such as https://acme.auth.example.com.
  2. Set SESSION_SECRET to at least 32 random bytes, such as the output of openssl rand -hex 32.
  3. Start the app and open /auth/handshake.
Setting
Issuer, IssuerFor The masks issuer, or a function that names one for each request.
Resource, ResourceFor The app’s own absolute https URL, or a function that names it for each request. Sign-ins name it as their resource, and tokens for the API must name it as their audience.
Name, NameFor The name the handshake gives the app, or a function that names it for each request. Defaults to the resource’s host.
Scope What every sign-in asks for. It must include openid. Defaults to openid profile email. Add offline_access to refresh tokens.
Namespace A scope namespace, such as catalog:, that the handshake registers once for every scope under it.
Privileges See privileges.
Organization A function that names the organization for each sign-in. See organizations.
ResourceScopes The scopes the app accepts in an access token. See accepting tokens.
Mount Where Handler answers. Defaults to /auth.
AfterSignIn, AfterSignOut Where a person goes after signing in or out. Both default to /.
SignOutOfIssuer Signs the person out of masks as well when they sign out of the app.
Secret, PreviousSecrets Seal the session. See sessions.
Sessions, SessionLifetime A store that keeps sessions on the server, and how long a session lasts. See sessions.
Store Where the app keeps what its processes share. See stores.
Credentials Where the handshake keeps the app’s credentials. Defaults to masks.json in the working directory.
Manages Who may reconnect or disconnect the app once it is connected. When it is not set, nobody can.
LoggedOut Called with each back-channel logout token.
Delegates, DelegationPath Turn on delegations, and name the path a provider connection comes back to.
HTTPClient, Options The http.Client for calls to masks, and other masks.Option values for the client package.
PlainHTTP For development. Drops the Secure flag and the __Host- prefix from the cookie, and allows a plain http issuer and resource.

Handler matches the full path, so it needs no prefix stripped. Each guard is a func(http.Handler) http.Handler.

r := chi.NewRouter()
r.Handle("/auth/*", app.Handler())
r.With(app.Require("admin")).Get("/admin", admin)
r := mux.NewRouter()
r.PathPrefix("/auth/").Handler(app.Handler())
r.Handle("/admin", app.Require("admin")(admin))

Gin and Echo take them through their own adapters, such as gin.WrapH and echo.WrapMiddleware.

The handshake connects the app to the masks server:

  1. The app sends the browser to the masks server’s approval screen.
  2. You approve the app there.
  3. A one-time token comes back. The app redeems it by talking to the masks server directly, so the client secret never passes through the browser.

The handshake registers the app for Resource. It registers these addresses under Mount:

/auth/callback The redirect URI for a sign-in.
/auth/handshake/callback Where masks sends the browser after the approval.
/auth/logout/return The only address masks sends the browser back to after a sign-out.
/auth/logout/backchannel Where masks posts a logout token, when the issuer supports back-channel logout.

The app writes its credentials to masks.json, with file mode 600, and then starts a sign-in. It reads the file again whenever it changes, so processes that share it see each other’s handshake. web.FileCredentials(path) names another file.

IssuerFor and ResourceFor name the issuer and the resource for each request. Derive them from something the app trusts, such as a tenant it looked up by host, and never from a value the browser sends unchecked. A session is held to the issuer that signed it in, so it does not carry over to another tenant.

Credentials names the issuer it came from, and the app refuses credentials from another issuer. With IssuerFor, web.New needs a CredentialStore that keeps each issuer’s credentials apart, since one masks.json would hand one tenant’s client secret to another. Its methods receive the request:

type CredentialStore interface {
Load(r *http.Request) (*web.Credentials, error)
Save(r *http.Request, held *web.Credentials) error
Forget(r *http.Request) error
}

The app keeps each issuer’s discovery document for an hour, then reads it again in the background. When masks cannot be reached, the app waits longer before each new try, up to a minute.

Once the app is connected, /auth/handshake shows the connection to somebody Manages allows, with buttons to connect again and to disconnect. The same check guards the handshake’s callback when the app is already connected.

Manages: func(r *http.Request, actor *web.Actor) bool { return actor.HasPrivilege("admin") },
  • A person who is not signed in goes to AfterSignOut. A JSON request gets 401 with a login_url.
  • Anybody else is refused with 403.

Disconnecting deletes the registration at masks and the credentials.

Method
app.Connected(r) Whether the app holds credentials.
app.Registered(r) Whether masks still knows the app. A sign-in checks this first, and when masks no longer knows the app, the browser goes to the handshake again.
app.Client(r) The masks.Client for the app, for calls of your own.

Handler answers these routes under Mount:

Route
GET /auth or GET /auth/ Starts a sign-in. Takes return_to, organization, and privilege.
GET /auth/callback Finishes it.
GET /auth/session The signed-in actor as JSON, for a single-page app.
GET /auth/avatar The person’s photo, fetched from masks with their token. Takes size.
GET, POST, or DELETE /auth/logout Signs out. Takes everywhere to sign out of masks as well. A browser that names no site gets a page first.
GET /auth/logout/return Where masks sends the browser back after signing it out.
POST /auth/logout/backchannel Takes a back-channel logout token from masks.
GET or POST /auth/handshake Starts the handshake, or shows the connection.
GET /auth/handshake/callback Finishes the handshake.
POST /auth/handshake/disconnect or DELETE /auth/handshake Disconnects the app.
  • A sign-in uses PKCE, a state, and a nonce.
  • The app checks the iss that masks sends back, and the ID token’s signature, audience, and nonce.
  • Up to five sign-ins can be in progress in one browser, each for an hour.
  • A callback the browser did not start sends the browser to sign in again, at most once a minute. In between, it is refused with invalid_state.
  • return_to is followed only to a path on the app.
Middleware
app.Session Puts the actor in the request’s context, signed in or not.
app.Require(privileges...) Sends a signed-out visitor to sign in and back again, then checks each privilege.
app.Protect(privileges...) Checks an access token instead. See accepting tokens.
app.Either(privileges...) Checks a token when the request carries an Authorization header or no session, and the session otherwise.

A JSON request gets 401 instead of a redirect, with login_required and a login_url, or handshake_required and a handshake_url.

web.ActorFrom(r.Context()) returns the *web.Actor for the person signed in, and never nil:

Field or method
SignedIn Whether a sign-in is held and its token has not expired.
Subject, Name, Nickname, Email, EmailVerified Who signed in.
Picture, Avatars The picture claim, then the photo, then the identicon, and each avatar URL.
Tenant The tenant’s UUID, Subdomain, and Name.
Organization, Organizations, HasRole("owner") See organizations.
Groups The groups the person belongs to.
Scopes, HasScope("catalog:read") What the token carries.
Privileges, HasPrivilege("admin") See privileges.
Holds(privilege), Member(privilege) Check a web.Privilege that Privileges does not name. Member checks only its role and organization, and returns a *web.Forbidden with the reason.
AccessToken The token to send to a resource server.
ExpiresAt, AccountURL When the access token expires, in seconds since the epoch, and the person’s account page at masks.
Claim("groups", &groups) Any other claim the sign-in kept.

GET /auth/session answers the same fields as JSON, without AccessToken.

app.LoginURL(web.LoginOptions{ReturnTo: "/billing"}) builds a link to sign in, and app.LogoutPath() is the path a sign-out form posts to.

A token that expires within 30 seconds is refreshed with its refresh token. The app reads the person’s name, photo, and organizations from userinfo again at the same time.

  • Requests that arrive together with the same expired token share one refresh, since masks revokes a refresh token that is used twice.
  • A refresh that masks refuses signs the person out.
  • When masks or the store cannot be reached, the request goes on as signed out. The session stays as it was, so the next request tries again.

A sign-out revokes the refresh token and clears the session. A sign-out of any method is refused with 403 when its Sec-Fetch-Site header is anything other than same-origin or none, or its Origin header names another origin. A POST or DELETE that sends neither header is refused too.

A GET sign-out signs out at once when its Sec-Fetch-Site header is same-origin or none. A GET with no Sec-Fetch-Site header, as older browsers send, does not sign out. A browser gets a page with a Sign out button that posts to /auth/logout and keeps everywhere. A JSON request gets 403. So a link on another site cannot sign anybody out.

With SignOutOfIssuer, or everywhere, the browser goes on to masks to end its session there. It comes back to /auth/logout/return, and goes on to AfterSignOut. That return is accepted once, within an hour, from the browser that signed out, and refused with invalid_state otherwise.

When masks ends a session itself, it sends a logout token to /auth/logout/backchannel. The app signs out the session the token names, or every session of the person it names, at their next request.

  • Each logout token is accepted once. A token with no jti, iat, or exp is refused.
  • The app remembers the logout in Store.
  • LoggedOut is called with each logout token. When it returns an error, the app answers 503, and accepts the same token when masks sends it again.

The session lives in a cookie, sealed with AES-GCM under a key derived from Secret.

  • It is HttpOnly, Secure, and SameSite=Lax, and named __Host-masks, so no other host can set it.
  • A session too large for one cookie is split across up to eight.
  • It lasts 14 days, or SessionLifetime, from the last time it changed, such as at a sign-in or a refresh.
  • The browser cannot read the tokens in it, and /auth/session leaves them out.
  • It is replaced at every sign-in.

To keep sessions on the server, set Sessions to a store. The cookie then holds only the session’s id, sealed, and the store holds the session, also sealed. A session in the store gets a new id at every sign-in and sign-out.

To change the secret, move the old one to PreviousSecrets. Sessions sealed with it still open, and each is sealed with the new secret the next time it changes.

Store holds what an app’s processes share:

  • back-channel logouts
  • the DPoP proofs Protect has seen
  • the result of each refresh, so that requests on several processes with the same expired token share one refresh
type Store interface {
Add(ctx context.Context, key string, value []byte, expiresIn time.Duration) (bool, error)
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, expiresIn time.Duration) error
Delete(ctx context.Context, key string) error
}

Add sets a key only when it is absent and answers whether it did. Get answers nil for a missing key. The default, &web.MemoryStore{}, keeps everything in one process. An app that runs as several processes uses Redis through github.com/masksrb/masks/go/web/redisstore:

store := redisstore.New(redis.NewClient(&redis.Options{Addr: "localhost:6379"}))
app, err := web.New(web.Config{
Store: store,
Sessions: store,
})

redisstore.New takes any redis.UniversalClient from github.com/redis/go-redis/v9, so a cluster or a sentinel works as well. When the store fails, a request that needs it is refused with 503.

An app signs a person in as a member of one organization when it asks for the organization scope. Name one for every sign-in with Organization, or for one sign-in with /auth/?organization=acme:

web.Config{
Scope: []string{"openid", "profile", "email", "offline_access", "organization"},
Organization: func(r *http.Request) string { return strings.Split(r.Host, ".")[0] },
}
actor.Organization The organization signed in to, with its ID, Key, Name, and Role, or nil.
actor.HasRole("owner", "billing") Whether the person holds one of those roles in it.
actor.Organizations Every organization the person has joined, each with the role held there.
app.LoginURL(web.LoginOptions{Organization: "globex"}) Signs the person in again, naming another organization.

The app reads the organizations from userinfo at sign-in and at every refresh, so a changed role reaches it within an access token’s lifetime.

A privilege is a name for what a route needs. It can ask for scopes the person holds in masks, a role in the organization they signed in to, or both:

web.Config{
Scope: []string{"openid", "profile", "email", "offline_access", "organization", "catalog:read"},
Privileges: map[string]web.Privilege{
"admin": {Scope: []string{"catalog:admin"}},
"billing": {Role: []string{"owner", "billing"}},
"acme_admin": {Scope: []string{"catalog:admin"}, Role: []string{"owner"}, Organization: "acme"},
},
}
  • Role takes one role or several. Any one of them is enough.
  • Organization limits the privilege to one organization, named by key or id.
mux.Handle("/settings", app.Require()(settings))
mux.Handle("POST /settings", app.Require("admin")(saveSettings))
The sign-in holds the privilege The handler runs.
A scope is missing The browser goes through masks once. It asks for the usual scopes and the privilege’s scopes, then comes back to the page it was on. A JSON request gets 403 with privilege_required, the privileges, and the login_url to send the browser to.
masks did not grant the scope on that trip 403 insufficient_scope.
A role or organization does not match 403 insufficient_role or insufficient_organization. The browser does not go through masks.

An ordinary sign-in never asks for privilege scopes. masks puts a scope in the token only for a person who already holds it, so asking never gives anyone more access. The handshake registers the scopes of every privilege, so the issuer lets the app ask for them.

Require, Protect, Either, and LoginURL panic on a name that Privileges does not define. In a URL, the app ignores the name.

An app that is also a resource server checks access tokens with Protect. Resource is the audience tokens must name, and ResourceScopes lists the scopes it accepts.

mux.Handle("GET /api/items", app.Protect("read")(items))
mux.Handle("GET /.well-known/oauth-protected-resource", app.ResourceMetadata())

Protect takes the same privileges, roles and organizations included, and checks them against the token:

  • A missing or invalid token is refused with 401. The challenge for a missing token carries no error code, as RFC 6750 asks. The one for an invalid token carries invalid_token.
  • A token that lacks a privilege is refused with 403 and insufficient_scope, insufficient_role, or insufficient_organization.
  • A refusal carries an RFC 6750 challenge whose resource_metadata points at /.well-known/oauth-protected-resource, which ResourceMetadata serves.
  • When the app cannot check a token, because masks could not be reached or the store failed, it answers 503.

A token bound to a key with DPoP is accepted only as Authorization: DPoP, with a proof that:

  • is signed by that key
  • names this request’s method, URL, and token
  • was made no more than 60 seconds ago, with 30 seconds of leeway for clocks that differ
  • has not been seen before

The URL is the resource’s origin and the request’s path, so a proxy in front of the app needs no other setting. The app remembers the proofs it has seen in Store.

Inside the handler, web.ActorFrom(r.Context()) describes the token: Subject, ClientID, Scopes, Organization, Tenant, and the verified Claims.

An API with no browser sign-in checks tokens with the client package alone. See checking tokens without go/web.

A delegation lets the app act as a person at a provider while they are away. Set Delegates and the path the provider’s answer comes back to. The handshake then asks for masks:delegate: and registers that path:

web.Config{
Delegates: true,
DelegationPath: "/connect/callback",
}
mux.HandleFunc("GET /connect", func(w http.ResponseWriter, r *http.Request) {
if err := app.StartDelegation(w, r, "google", masks.AuthRequest{}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
})
mux.HandleFunc("GET /connect/callback", func(w http.ResponseWriter, r *http.Request) {
connected, err := app.FinishDelegation(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
save(connected.Connection, connected.Secret)
})

StartDelegation sends a person who is not signed in to sign in first, and returns no error. A request for JSON gets 401 with login_required. FinishDelegation refuses a connection that this browser did not start, that somebody other than the person signed in started, or that masks made for another account.

Keep the Secret encrypted. To call the provider later, ask for its token:

client, err := app.Delegations(r)
upstream, err := client.UpstreamToken(r.Context(), secret, connection)
save(connection, upstream.Secret)

Save upstream.Secret before anything else, since each call replaces the secret. A *masks.DelegationError carries the replacement in Secret even when the call fails after the refresh. The upstream token is a Bearer token, even when the client holds a DPoP key.

  • errors.Is(err, masks.ErrDelegationUnavailable) means masks or the provider did not answer, and nothing was lost.
  • errors.Is(err, masks.ErrDelegationRefused) means masks refused the call. Its SignInAgain() is true when the person has to sign in again.

A Go program on a machine, such as an agent an app installs, signs in as itself with the client package. See enrolling a machine for how masks treats it.

An app that has claimed a namespace, through Namespace in its handshake, enrolls each machine without a browser. It asks masks for an initial access token and hands it to the machine:

client, err := app.Client(r)
enrollment, err := client.Enroll(r.Context(), masks.EnrollmentRequest{
Scope: []string{"catalog:agent"},
Name: "nas",
Lifetime: 15 * time.Minute,
})
  • Each scope falls under one of the app’s namespaces.
  • The token lasts from 1 minute to 1 hour, and 15 minutes when Lifetime is not set.
  • The token is spent the first time a machine registers with it.

client.Unenroll(ctx, machineClientID) archives the machine and revokes every token it holds. Unenrolling a machine twice succeeds both times. A client the app did not enroll answers with a *masks.Error whose Status is 404.

The machine keeps two P-256 keys. One signs its client assertions and the other holds its tokens, so a token copied off the machine does not work without the key.

key, err := masks.GenerateKey()
dpop, err := masks.GenerateKey()
saved, err := json.Marshal(map[string]*masks.Key{"key": key, "dpop": dpop})

json.Marshal writes the private key, so keep the file readable only by the machine’s service user. json.Unmarshal reads the keys back.

The machine registers its public key with the token. masks requires JWKS and refuses JWKSURI.

issuer, err := masks.Discover(ctx, "https://acme.auth.example.com")
registration, err := issuer.Register(ctx, masks.ClientMetadata{
Name: "nas",
TokenEndpointAuthMethod: masks.PrivateKeyJWT,
JWKS: key.JWKS(),
}, enrollment.InitialAccessToken)
client := issuer.Client(registration, key, dpop)

Keep the registration. ReadRegistration, UpdateRegistration, and DeleteRegistration manage the client with its registration access token, and send that token only to the issuer’s own origin.

asked := masks.TokenRequest{Scope: []string{"files:read"}, Resource: []string{"https://files.example.com"}}
token, err := client.ClientCredentials(ctx, asked)

client.Method defaults to client_secret_basic when Secret is set and to private_key_jwt when Key is. When DPoP is set, a token that comes back unbound is refused.

HTTPClient returns an http.Client that fetches and reuses tokens and signs each request. It sends tokens only to the origins it is given, including after a redirect.

api := client.HTTPClient(client.CredentialsSource(asked), "https://files.example.com")
response, err := api.Get("https://files.example.com/files")

A token held to a key goes out as Authorization: DPoP with a new proof, and a server that asks for a DPoP nonce is answered once. RefreshSource renews a token with its refresh token and hands each rotated token to a callback to be saved.

A machine with no browser shows a code that a person approves on another device.

device, err := client.DeviceAuthorization(ctx, masks.TokenRequest{Scope: []string{"openid", "offline_access"}})
fmt.Printf("Open %s and enter %s\n", device.VerificationURI, device.UserCode)
token, err := client.AwaitDevice(ctx, device)

AwaitDevice polls at the interval masks names, slows down when asked, and stops when the code expires or ctx ends.

Exchange trades a token for another one with fewer scopes, another audience, or a shorter lifetime.

narrowed, err := client.Exchange(ctx, masks.ExchangeRequest{
SubjectToken: token.AccessToken,
Scope: []string{"files:read"},
Resource: []string{"https://files.example.com"},
Lifetime: 5 * time.Minute,
})
  • SubjectTokenType and ActorTokenType default to an access token.
  • An ActorToken puts the party acting for the subject in the new token’s act claim.
  • When DPoP is set, the new token comes back held to the same key.
  • An answer whose issued_token_type differs from the one asked for is refused.
introspected, err := client.Introspect(ctx, token.AccessToken, "access_token")
if introspected.HasScope("files:read") {
serve()
}

masks describes a token to the client it was issued to. It also describes it to an approved client whose resources include one of the token’s audiences, unless an app enrolled that client. Every other token comes back inactive. An inactive answer, or one whose exp has passed, returns an Introspection with every field empty, and HasScope is false.

verifier := masks.NewVerifier(issuer, "https://files.example.com")
verifier.PublicURL = "https://files.example.com"
http.Handle("/files", verifier.Middleware(files))

The verifier checks the signature against the issuer’s published keys, the at+jwt type, the issuer, the audience, and the expiry. It checks a DPoP proof the same way Protect does. Inside the handler, masks.ClaimsFrom(ctx) returns the claims, and claims.HasScope checks a scope.

  • Set PublicURL when a proxy sits in front of the API, since a proof names the address the client used.
  • A token that fails its checks answers 401 with a challenge that carries invalid_token. A request with no token answers 401 with a challenge that carries no error code.

A verifier remembers the proofs it has seen in its own process. When an API runs as several processes, set Replay to a masks.ReplayStore they share. Its First answers true only the first time it sees a key. A store in Redis sets the key only when it is absent:

verifier.Replay = masks.ReplayStoreFunc(func(ctx context.Context, key string, expiresIn time.Duration) (bool, error) {
return redis.SetNX(ctx, "dpop:"+key, 1, expiresIn).Result()
})

An error from the store refuses the request with masks.ErrReplayStore, and the middleware answers 503.

A request that carries a credential does not follow a redirect. That covers the token, revocation, introspection, device, enrollment, and registration endpoints, so a client secret, a code, or a refresh token goes only to the address the issuer published. A redirect there is a *masks.Error.

Every URL is https, except on this machine: localhost, a .localhost name, or a loopback address. masks.AllowPlainHTTP() lifts that for a private network.