Go
The forculuslabs.tech/sdk module verifies the identity token the edge sends your app and gives you the caller. Its middleware is a plain func(http.Handler) http.Handler, so it works with net/http and most routers.
go get forculuslabs.tech/sdkimport forculus "forculuslabs.tech/sdk"The middleware
Section titled “The middleware”forculus.Middleware answers 401 without a valid caller. Otherwise it puts the caller in the request’s context, and forculus.UserFrom reads it back.
http.Handle("/", forculus.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := forculus.UserFrom(r.Context()) fmt.Fprintf(w, "hello %s\n", user.Email)})))UserFrom returns false as its second value when the middleware didn’t run on that request.
The caller is a forculus.User:
type User struct { Kind string // "user", or "service" for a bypass token or an agent Sub string // "usr_…", "service:<token id>", or "agent:<agent id>" Email string // empty for a bypass token or an agent Role string // "viewer", "editor" or "owner" WorkspaceID string AgentName string // the agent's name when an agent called, empty for everyone else}user.IsAgent() says whether one of the workspace’s agents made the request. It and AgentName are new in v0.2.0: with an earlier version, go get forculuslabs.tech/sdk@latest.
by := user.Emailif user.IsAgent() { by = user.AgentName + " (agent)"}See the SDK overview for what each field means and what the SDK checks.
json.Marshal(user) writes kind, sub, email, role, workspace_id and agent_name, the names Python and Rust use. A bypass token’s email is null, and so is the agent name of anyone but an agent.
In a handler
Section titled “In a handler”Call forculus.RequireUser where you’d rather check inside the handler:
user, err := forculus.RequireUser(r)if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return}Every refusal wraps forculus.ErrUnauthorized, so errors.Is(err, forculus.ErrUnauthorized) is true for all of them.
forculus.Requires is the middleware for a route only some callers may reach. It answers 401 without a caller, and 403 when the caller has less than the role it names.
mux.Handle("POST /orders", forculus.Requires(forculus.RoleEditor)(http.HandlerFunc(placeOrder)))In a handler:
if err := forculus.RequireRole(user, forculus.RoleEditor); err != nil { http.Error(w, "forbidden", http.StatusForbidden) // errors.Is(err, forculus.ErrForbidden) return}
if forculus.HasRole(user, forculus.RoleOwner) { // show the settings link}The roles are forculus.RoleViewer, forculus.RoleEditor and forculus.RoleOwner.
Your router
Section titled “Your router”// chir.Use(forculus.Middleware)r.With(forculus.Requires(forculus.RoleEditor)).Post("/orders", placeOrder)
// echoe.Use(echo.WrapMiddleware(forculus.Middleware))
// gin, whose middleware has a shape of its ownr.Use(func(c *gin.Context) { user, err := forculus.RequireUser(c.Request) if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } c.Set("user", user)})Webhooks
Section titled “Webhooks”A delivery on a verified public path arrives with a token of kind webhook. RequireWebhook checks it and says which provider signed the delivery. RequireUser refuses a webhook’s token, and RequireWebhook refuses a person’s.
mux := http.NewServeMux()// Outside Middleware, which would refuse the delivery before this handler saw it.mux.Handle("POST /hooks/stripe", forculus.WebhookMiddleware(forculus.ProviderStripe)(http.HandlerFunc(stripeHook)))mux.Handle("/", forculus.Middleware(app))
func stripeHook(w http.ResponseWriter, r *http.Request) { hook, _ := forculus.WebhookFrom(r.Context()) if handled(hook.Delivery) { // Stripe's event id w.WriteHeader(http.StatusNoContent) return } // decode r.Body as usual w.WriteHeader(http.StatusNoContent)}Or call hook, err := forculus.RequireWebhook(r, forculus.ProviderStripe) in a handler. With chi, give the webhook’s route its own middleware rather than the router’s Use:
r.Group(func(r chi.Router) { r.Use(forculus.Middleware) r.Get("/", index)})r.With(forculus.WebhookMiddleware(forculus.ProviderStripe)).Post("/hooks/stripe", stripeHook)WebhookMiddleware answers 401 unauthorized to anything but a verified delivery from the providers it names, and otherwise puts the delivery in the request’s context for WebhookFrom. WebhookFrom returns false as its second value when the middleware didn’t run on that request.
Name the providers the route takes: ProviderStripe, ProviderGitHub, ProviderSlack, ProviderShopify, ProviderStandardWebhooks or ProviderHMACSHA256. A delivery from any other is refused, and so is every delivery when none is named. The delivery is a forculus.Webhook:
type Webhook struct { Provider Provider // ProviderStripe and the rest Pattern string // the public path it came in on, like "/hooks/stripe" Delivery string // the provider's signed id, empty where it signs none SignedAt time.Time // zero where the provider signs no time WorkspaceID string}As JSON a delivery is provider, pattern, delivery, signed_at and workspace_id, the names Python and Rust use. signed_at is unix seconds, and an id or time the provider doesn’t sign is null.
The body arrives byte for byte as the provider sent it, so decode r.Body as usual.
Configuration
Section titled “Configuration”The package-level functions (Middleware, Requires, RequireUser, RequireWebhook and WebhookMiddleware) read FORCULUS_APP_ID, FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL from the environment the first time they run, and keep what they found for the life of the process. We set all three.
To pass the values yourself, build a Verifier. It has the same methods, and is safe to share between goroutines.
verifier, err := forculus.NewVerifier(forculus.Config{ AppID: os.Getenv("FORCULUS_APP_ID"), Issuer: "https://edge.forculuslabs.tech", JWKSURL: "https://edge.forculuslabs.tech/.well-known/jwks.json",})if err != nil { log.Fatal(err) // a field was empty}
http.Handle("/", verifier.Middleware(handler))Config also has Dev, whether FORCULUS_DEV is 1. ConfigFromEnv reads it. Only forculus dev sets it, and without it NewVerifier refuses an http issuer or key set.
verifier.Verify(token) checks a token you already have, the same way, and verifier.VerifyWebhook(token, forculus.ProviderStripe) a webhook’s.
Refused requests
Section titled “Refused requests”| Status | Body | When |
|---|---|---|
| 401 | unauthorized |
No valid caller |
| 403 | forbidden: this needs editor |
Requires(RoleEditor) and the caller has less |
Both come with Cache-Control: no-store.
Errors
Section titled “Errors”The error says why, for your logs:
err.Error() |
Why |
|---|---|
unauthorized: no identity token |
The request has no X-Forculus-Identity header |
unauthorized: invalid identity token |
The token failed a check, or the keys couldn’t be fetched |
unauthorized: FORCULUS_APP_ID, FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must be set |
The variables weren’t in the environment |
unauthorized: FORCULUS_IDENTITY_ISSUER and FORCULUS_JWKS_URL must use https, except under forculus dev (FORCULUS_DEV=1, on this machine) |
The issuer or key set is http, and FORCULUS_DEV isn’t 1. Only forculus dev uses http |
unauthorized: not a webhook this route takes |
From RequireWebhook: a provider the route doesn’t name signed the delivery |
unauthorized: invalid webhook token |
From RequireWebhook: the token’s webhook claims failed a check |
unauthorized: no provider named |
From RequireWebhook: the call named no provider, so it refuses everything |
forbidden: this needs editor |
From RequireRole(user, forculus.RoleEditor) |
A person’s token given to RequireWebhook, or a webhook’s given to RequireUser, fails as invalid identity token, because its audience is wrong for that call.
Run it locally
Section titled “Run it locally”forculus dev runs your app on your machine behind a copy of our front door, with real tokens. With a main.go in the project it runs go run .. Otherwise, give the command, as in forculus dev -- go run ./cmd/server. See Local development.
forculus devNext steps
Section titled “Next steps”- SDK overview: every claim in the token, and each check.
- Local development.
- Webhooks and public paths.