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.
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. |
Setting up
Section titled “Setting up”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))- Set
MASKS_ISSUERto the tenant you sign in against, such ashttps://acme.auth.example.com. - Set
SESSION_SECRETto at least 32 random bytes, such as the output ofopenssl rand -hex 32. - 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. |
With other routers
Section titled “With other routers”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
Section titled “The handshake”The handshake connects the app to the masks server:
- The app sends the browser to the masks server’s approval screen.
- You approve the app there.
- 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.
Several tenants
Section titled “Several tenants”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.
Managing the connection
Section titled “Managing the connection”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 gets401with alogin_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. |
Signing in
Section titled “Signing in”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 anonce. - The app checks the
issthat 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_tois 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.
Refreshing
Section titled “Refreshing”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.
Signing out
Section titled “Signing out”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, orexpis refused. - The app remembers the logout in
Store. LoggedOutis called with each logout token. When it returns an error, the app answers503, and accepts the same token when masks sends it again.
Sessions
Section titled “Sessions”The session lives in a cookie, sealed with AES-GCM under a key derived from Secret.
- It is
HttpOnly,Secure, andSameSite=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/sessionleaves 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.
Stores
Section titled “Stores”Store holds what an app’s processes share:
- back-channel logouts
- the DPoP proofs
Protecthas 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.
Organizations
Section titled “Organizations”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.
Privileges
Section titled “Privileges”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"}, },}Roletakes one role or several. Any one of them is enough.Organizationlimits 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.
Accepting tokens
Section titled “Accepting tokens”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 carriesinvalid_token. - A token that lacks a privilege is refused with
403andinsufficient_scope,insufficient_role, orinsufficient_organization. - A refusal carries an RFC 6750 challenge whose
resource_metadatapoints at/.well-known/oauth-protected-resource, whichResourceMetadataserves. - 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.
Delegations
Section titled “Delegations”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. ItsSignInAgain()istruewhen the person has to sign in again.
Machines
Section titled “Machines”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.
Enrolling a machine
Section titled “Enrolling a machine”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
Lifetimeis 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.
Registering with the token
Section titled “Registering with the token”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.
Getting tokens
Section titled “Getting tokens”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.
The device flow
Section titled “The device flow”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.
Exchanging a token
Section titled “Exchanging a token”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,})SubjectTokenTypeandActorTokenTypedefault to an access token.- An
ActorTokenputs the party acting for the subject in the new token’sactclaim. - When
DPoPis set, the new token comes back held to the same key. - An answer whose
issued_token_typediffers from the one asked for is refused.
Introspecting a token
Section titled “Introspecting a token”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.
Checking tokens without go/web
Section titled “Checking tokens without go/web”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
PublicURLwhen a proxy sits in front of the API, since a proof names the address the client used. - A token that fails its checks answers
401with a challenge that carriesinvalid_token. A request with no token answers401with 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.
Redirects and plain HTTP
Section titled “Redirects and plain HTTP”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.