A Seamless Auth server adapter for Go's
net/http, so it works with chi, Echo and Gin as well.
The adapter sits in your backend between your users and the Seamless Auth API:
- Browsers talk to it over
HttpOnlycookies on your own domain. The API's tokens never reach page scripts. - Native clients (mobile, CLIs) talk to it over bearer tokens, sending
x-seamless-auth-transport: bearer. - The auth API sees bearer tokens plus a service token that lets it trust the client address and user agent the adapter forwards.
Which routes the adapter serves, and what each does to the session, comes from the adapter manifest the auth API publishes, so a new API route works without a new release of this module. It has no dependencies outside the Go standard library.
It is held to the same conformance suite as the Express and Fastify adapters, in CI on every change.
go get github.com/fells-code/seamless-auth-goGo 1.22 or later.
package main
import (
"log"
"net/http"
"os"
seamlessauth "github.com/fells-code/seamless-auth-go"
)
func main() {
auth, err := seamlessauth.New(seamlessauth.Options{
AuthServerURL: os.Getenv("AUTH_SERVER_URL"),
CookieSecret: os.Getenv("COOKIE_SECRET"), // at least 32 characters
ServiceSecret: os.Getenv("SEAMLESS_SERVICE_TOKEN"), // same value as the API's API_SERVICE_TOKEN
JWKSKid: os.Getenv("JWKS_KID"),
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
// The auth routes, at /auth, which is where the client SDKs call.
mux.Handle("/auth/", http.StripPrefix("/auth", auth.Handler()))
// Your own routes, behind the adapter's guard.
mux.Handle("GET /api/me", auth.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, _ := seamlessauth.UserFromContext(r.Context())
w.Write([]byte(user.ID))
})))
log.Fatal(http.ListenAndServe(":8080", mux))
}The auth server reads the service token from API_SERVICE_TOKEN and your app reads it from
SEAMLESS_SERVICE_TOKEN; both hold the same value.
With other routers:
// chi
r.Mount("/auth", http.StripPrefix("/auth", auth.Handler()))
r.With(auth.RequireAuth).Get("/api/me", me)
// Gin
g.Any("/auth/*path", gin.WrapH(http.StripPrefix("/auth", auth.Handler())))RequireAuth answers 401 unless the request carries a session, and puts a *User on the
request context (UserFromContext). It accepts the adapter's session cookie, or an auth API
access token in Authorization: Bearer for clients with no cookie jar. The cookie wins when
both are present. It reads the Cookie header itself, so it works on any route. It does not
refresh: the auth routes refresh a browser session silently, and a bearer client calls
POST /auth/refresh itself. Authenticate does the same check without answering.
A request RequireAuth admits on the session cookie also gets the auth routes' cross-site
check: while cookies are SameSite=None, a state-changing request from another site
(Sec-Fetch-Site: cross-site, or an Origin outside AllowedOrigins) answers 403
cross_site_request_blocked. A bearer token is never attached by a browser, so it is not
checked. Authenticate makes no such check.
The auth routes forward a request body to the auth API as JSON, so a body must be sent as
application/json (or a +json type). Anything else answers 415 unsupported_media_type: a
cross-site form can post a text/plain body shaped like JSON with no CORS preflight, and read as
JSON it would be a sign-in the user never made. The client SDKs already send JSON.
| Option | Default | Purpose |
|---|---|---|
AuthServerURL |
required | Where the adapter reaches the auth API |
AuthServerIssuer |
AuthServerURL |
Expected iss of the API's tokens, when it differs from the URL you reach it at |
Audience |
AuthServerIssuer |
Expected aud of the API's tokens |
CookieSecret |
required | Signs the session cookies (32 characters or more) |
ServiceSecret |
required | The service token, read from SEAMLESS_SERVICE_TOKEN in the examples; the same value as the API's API_SERVICE_TOKEN (32 characters or more) |
JWKSKid |
dev-main |
kid header on service tokens |
CookieDomain |
none | Cookie Domain |
InsecureCookies |
false |
Drops Secure, for local development over HTTP |
CookieSameSite |
None, or Lax with InsecureCookies |
Cookie SameSite |
AllowedOrigins |
none | The only cross-origin callers allowed to change state while cookies are SameSite=None |
AccessCookieName, RefreshCookieName |
seamless-access, seamless-refresh |
Session cookie names |
RegistrationCookieName, PreAuthCookieName |
seamless-ephemeral |
Sign-in flow cookie names |
Deliver |
none | Sends OTP codes and magic links through your own transports |
ResolveClientIP |
the connecting peer | The end user's address. See below |
DisableManifestFetch |
false |
Use only the manifest bundled with this version |
HTTPClient, Logf |
defaults | Outbound client and logger |
The adapter forwards the end user's address and user agent so the auth API rate limits and audits against the user, not your server. Behind a proxy, name it:
ResolveClientIP: seamlessauth.TrustedProxies("10.0.0.0/8"),TrustedProxies walks X-Forwarded-For from the right, skipping trusted proxies. There is
deliberately no hop-count option: a hop count cannot tell a proxy from a client that sent its
own header.
Without Deliver, the auth API sends OTP codes and magic links itself. With it, the adapter
asks the API for the message and hands it to you:
Deliver: func(ctx context.Context, d seamlessauth.Delivery) error {
return mailer.Send(ctx, d.To, d.Kind, d.Token, d.MagicLinkURL)
},A delivery error answers the request with 502 delivery_failed.
The auth API signs a user in from a magic link only on the device that asked for it. On
GET /magic-link/verify/{token} the adapter forwards that device's pre-auth token when it has
one: the client's own bearer token in bearer transport, or the pre-auth cookie in cookie
transport if it verifies. Without one it sends none, and never answers 401, because the API
falls back to the client address and user agent the adapter forwards.
Opened anywhere else, the link answers 409 confirmation_required, with requestedFrom
(deviceClass, requestedAt) describing the request, and is not consumed. Once the user
approves it, POST /magic-link/verify/{token}/confirm consumes it. The requesting attempt's
first GET /magic-link/check after that issues the session; later checks answer
{ "message": "Success" } with no tokens, and the adapter then leaves the cookies as they are.
ConsoleHandler serves the Seamless admin dashboard from your API, proxied from the auth API, so
it shares the origin and cookie scope of the /auth routes:
mux.Handle("/console/", http.StripPrefix("/console", auth.ConsoleHandler()))It serves GET and HEAD only and forwards nothing but the method and path, so the browser's
cookies never reach the upstream. It refuses any path that could leave the console (dot segments,
encoded separators), and follows a redirect only while it stays inside the console on the auth API.
When you serve it, add your API's origin to the auth server's ORIGINS so passkey ceremonies
started in the console verify.
On its first request the adapter fetches /.well-known/seamless-adapter.json from the auth API
(5 second timeout) and keeps it for the life of the process. If the API serves none, it uses the
copy embedded in this module and tries again a minute later. A manifest with anything this
version does not understand is refused whole, rather than following a route with the wrong
token. Refresh the embedded copy with scripts/sync-manifest.sh.
conformance/refapp is the reference app the conformance suite drives. To run it locally you
need Docker, a checkout of seamless-auth-api and the seamless CLI:
PORT=8080 AUTH_SERVER_URL=http://localhost:5312 AUTH_SERVER_ISSUER=http://auth-api:5312 \
SEAMLESS_SERVICE_TOKEN=verify-dev-service-token-not-a-real-secret \
COOKIE_SIGNING_KEY=verify-dev-service-token-not-a-real-secret JWKS_KID=dev-main \
go run ./conformance/refapp &
SEAMLESS_API_DIR=../seamless-auth-api seamless verify --adapter-url=http://localhost:8080Pre-1.0. The public API may change between minor versions until 1.0.
Apache-2.0