Base IdP

Base IdP

SDKs

Go

The baseidp Go package — offline PASETO verification, net/http middleware, and a typed principal.

The Go SDK is what you reach for when your backend is a Go service sitting behind a frontend. It verifies Base IdP tokens offline against published public keys, ships a net/http middleware, and returns a typed principal your handlers can read. Verify-only services do not need any Base IdP env at all.

The package lives under the main Base IdP repo, exported as a Go module. There is nothing to set up beyond go get.

Install

go get github.com/squareexp/base-idp/sdk/go

Import it as baseidp:

import baseidp "github.com/squareexp/base-idp/sdk/go"

Quick start

A verify-only service looks like this in full:

main.go
package main

import (
	"fmt"
	"log"
	"net/http"

	baseidp "github.com/squareexp/base-idp/sdk/go"
)

func main() {
	client := baseidp.MustNew(baseidp.ConfigFromEnv())

	mux := http.NewServeMux()
	mux.Handle("/me", client.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p, _ := baseidp.PrincipalFromContext(r.Context())
		fmt.Fprintf(w, "Hello %s (%s)\n", p.DisplayName, p.Email)
	}), baseidp.MiddlewareOptions{}))

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

That is the whole integration. The middleware verifies the bearer token, attaches the principal to the request context, and rejects everything that fails verification with a 401.

Building a client

Config from env

ConfigFromEnv is the idiomatic constructor. It reads BASE_IDP_CLIENT_ID if present, falls back to defaults otherwise, and produces a ready-to-use Config.

client := baseidp.MustNew(baseidp.ConfigFromEnv())

Config by hand

You can also construct the config directly if you need to.

client, err := baseidp.New(baseidp.Config{
    ClientID:      "sq_live_yourapp",
    RequiredScope: "tasks:read",
    ClockSkew:     30 * time.Second,
    KeyCacheTTL:   10 * time.Minute,
})
if err != nil {
    log.Fatal(err)
}

Most fields have sensible defaults. The ones you might tune in production:

FieldDefaultWhen to change
ClientIDemptySet to pin the audience.
RequiredScopeemptyReject tokens without this scope.
ClockSkew30sTolerance for clock drift between hosts.
KeyCacheTTL10mHow long to cache the public-key set.
HTTPClienthttp.DefaultClientOverride for custom transports, timeouts, or test fakes.

Middleware

Middleware

The plain middleware wraps a handler and only invokes the next handler if the token is valid. On failure it writes a 401.

mux.Handle("/me", client.Middleware(baseidp.MiddlewareOptions{})(handler))

RequireAuth

A shortcut that takes the next handler directly. This is the form most projects use.

mux.Handle("/me", client.RequireAuth(handler, baseidp.MiddlewareOptions{}))

MiddlewareOptions

type MiddlewareOptions struct {
    VerifyOptions
    CookieName   string                                            // alternative source for the token
    ErrorHandler func(http.ResponseWriter, *http.Request, error)   // custom error response
}

The VerifyOptions are embedded, so you can set RequiredScope, an explicit Audience, or a MaxClockSkewSeconds per-route if you need different rules for different endpoints.

Reading the principal

Inside a handler, pull the principal off the context.

func handleMe(w http.ResponseWriter, r *http.Request) {
    p, ok := baseidp.PrincipalFromContext(r.Context())
    if !ok {
        http.Error(w, "no principal", http.StatusUnauthorized)
        return
    }
    fmt.Fprintf(w, "user %s (%s)", p.Subject, p.Email)
}

Verifying tokens directly

If middleware is the wrong shape for your codebase (a gRPC interceptor, a custom auth layer, a worker that processes queued jobs with embedded tokens), call VerifyAccessToken directly.

ctx := r.Context()
principal, err := client.VerifyAccessToken(ctx, token, baseidp.VerifyOptions{
    RequiredScope: "tasks:read",
})
if err != nil {
    return fmt.Errorf("token rejected: %w", err)
}

The Principal struct

type Principal struct {
    ID             string   // gid
    Subject        string   // sub
    Email          string
    DisplayName    string
    Scopes         []string
    Role           string
    SessionID      string
    AssuranceLevel int
    Audience       string
    Issuer         string
    ExpiresAt      time.Time
    IssuedAt       time.Time
}

Reach for the fields you need. Subject and Email are the most common.

Running the full login (confidential)

If your Go service runs the login itself — a server-rendered web app, a Hugo-style frontend with a Go backend — pass the secret and use the authorize and exchange helpers.

client := baseidp.MustNew(baseidp.Config{
    ClientID:    "sq_live_web",
    Secret:      os.Getenv("BASE_IDP_CLIENT_SECRET"),
    RedirectURI: "https://app.example.com/auth/callback",
})

// 1. Build the authorize URL.
url, err := client.AuthorizeURL(baseidp.AuthorizeOptions{
    State: "return-to=/dashboard",
})
http.Redirect(w, r, url, http.StatusFound)
// 2. Handle the callback.
code := r.URL.Query().Get("code")
tokens, err := client.ExchangeCode(r.Context(), baseidp.TokenOptions{
    Code:         code,
    CodeVerifier: storedVerifier,
})
// 3. Verify the access token.
principal, err := client.VerifyAccessToken(r.Context(), tokens.AccessToken, baseidp.VerifyOptions{})

Refresh

tokens, err := client.Refresh(ctx, baseidp.RefreshOptions{
    RefreshToken: stored.RefreshToken,
})

The refresh token rotates on each call — the response always carries a new refresh token, and the old one is invalidated.

Discovery and keys

The SDK fetches the discovery document and the public keys automatically. You can also call them directly for diagnostics.

metadata, err := client.Discovery(ctx, false)
keys, err := client.PublicKeys(ctx, false)

Pass true to force a refresh that bypasses the cache.

A complete API example

cmd/api/main.go
package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"

	baseidp "github.com/squareexp/base-idp/sdk/go"
)

func main() {
	client := baseidp.MustNew(baseidp.ConfigFromEnv())

	mux := http.NewServeMux()

	mux.Handle("/v1/me", client.RequireAuth(
		http.HandlerFunc(handleMe),
		baseidp.MiddlewareOptions{},
	))

	mux.Handle("/v1/admin", client.RequireAuth(
		http.HandlerFunc(handleAdmin),
		baseidp.MiddlewareOptions{
			VerifyOptions: baseidp.VerifyOptions{
				RequiredScope: "admin",
			},
		},
	))

	addr := ":" + getenvOr("PORT", "8080")
	log.Printf("listening on %s", addr)
	log.Fatal(http.ListenAndServe(addr, mux))
}

func handleMe(w http.ResponseWriter, r *http.Request) {
	p, _ := baseidp.PrincipalFromContext(r.Context())
	json.NewEncoder(w).Encode(map[string]any{
		"id":    p.Subject,
		"email": p.Email,
		"name":  p.DisplayName,
	})
}

func handleAdmin(w http.ResponseWriter, r *http.Request) {
	_, _ = baseidp.PrincipalFromContext(r.Context())
	w.Write([]byte(`{"status":"ok"}`))
}

func getenvOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

A verify-only Go API with two routes, scope enforcement on the admin route, and zero Base IdP env required. That is the production shape for most services.

Where to go next

On this page