authgate

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

github.com/ubgo/auth/authgate

A login gate, not user accounts: sign in with any OAuth 2.0 or OpenID Connect provider and keep the person's profile in a sealed cookie. No database, no user table, no auth.New.

For an internal tool behind company login, a prototype, a tool acting on the person's GitHub or Google account, or a serverless function with nowhere to keep rows. The guide with a full walkthrough is docs/integrations/login-gate.md; the runnable example is examples/login-gate.

What it does not do

No per-device sign-out, no passwords or second factors of its own, no bans, no admin, no user list, no account linking. Revocation is all or nothing: raise SessionVersion (or drop a secret) and everyone signs in again; each cookie also expires at an absolute time. When you need any of that, use auth.New with plugin/oauth; the same provider values work there.

Install

go get github.com/ubgo/auth

It is in the core module and uses only the standard library, plugin/oauth's providers and authhttp's error writer.

Wire it

newGate · examples/login-gate/main.go:84-118

func newGate(getenv func(string) string) (*authgate.Gate, error) {
	var providers []oauth.Provider
	if id, secret := getenv(envGoogleID), getenv(envGoogleSecret); id != "" && secret != "" {
		providers = append(providers, oauth.Google(id, secret))
	}
	if iss, id, secret := getenv(envOIDCIssuer), getenv(envOIDCID), getenv(envOIDCSecret); iss != "" && id != "" && secret != "" {
		providers = append(providers, oauth.OIDC(oidcProviderName, iss, id, secret))
	}
	if len(providers) == 0 {
		return nil, errNoProvider
	}
	domain := getenv(envAllowedDomain)
	if domain == "" {
		return nil, errNoDomain
	}
	version := uint64(0)
	if v := getenv(envSessionVersion); v != "" {
		n, err := strconv.ParseUint(v, 10, 64)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", envSessionVersion, err)
		}
		version = n
	}
	g, err := authgate.New(
		authgate.WithProviders(providers...),
		authgate.WithSecret([]byte(getenv(envSecret))),
		authgate.WithBaseURL(baseURL),
		authgate.AllowEmailDomain(domain),
		authgate.SessionVersion(version),
	)
	if errors.Is(err, auth.ErrSecretTooShort) {
		return nil, fmt.Errorf("set %s to at least %d bytes, such as the output of openssl rand -base64 32: %w", envSecret, auth.MinSecretSize, err)
	}
	return g, err
}

newHandler · examples/login-gate/main.go:124-129

func newHandler(g *authgate.Gate) http.Handler {
	mux := http.NewServeMux()
	mux.Handle(authPrefix+"/", g.Handler())
	mux.Handle("/{$}", g.RequireUser(http.HandlerFunc(dashboard)))
	return mux
}

Options

Option Default What it does
WithProviders(ps ...oauth.Provider) required the providers: oauth.Google, oauth.GitHub, a preset, oauth.OIDC, or your own oauth.Provider; each passes oauth.ValidateProvider
WithSecret(current, previous...) required at least 32 bytes; the cookie keys are derived with auth.DeriveSecretKeys under auth.KeyLabelAuthGate; previous secrets still open
WithBaseURL(origin) required the public origin, http or https, no path; the callback is origin + prefix + /{provider}/callback
Allow(func(Profile) bool) one rule required admits a profile; any rule admitting is enough; rechecked on every request, so keep it cheap and offline
AllowEmailDomain(domains...) a verified address at exactly one of the domains, case-blind
AllowEmails(addrs...) a verified address that is one of addrs, case-blind
AllowAnyone() every account the providers sign in
SessionLifetime(d) 7 days absolute; never extended by activity
SessionVersion(n) 0 only cookies stamped with n open; raise it to sign everyone out
KeepTokens() off keep access and refresh token, expiry and scope in the cookie (Profile.Tokens)
Prefix(path) /auth where Handler is mounted
SignInPage(path) the only provider's start where RequireUser sends a browser that is not signed in; with several providers and none set, 401
ErrorHandler(fn) JSON error body render errors as your own page
WithHTTPClient(hc) 10 s timeout the client that reaches the providers
WithLogger(l) slog.Default() refused sign-ins (Info), provider failures (Warn), oversized cookies (Error)
WithClock(now) time.Now tests

The limits are constants: DefaultSessionLifetime, FlowLifetime, MaxCookieSize, MaxReturnToLength.

Routes

Method and path What it does
GET {prefix}/{provider}/start?returnTo=/path seals state, PKCE verifier and return path into the flow cookie; redirects to the provider
GET {prefix}/{provider}/callback checks state and issuer, exchanges the code, applies the rules, writes the session cookie, redirects to the return path
POST {prefix}/{provider}/callback form-post providers only: relays to the GET callback
POST {prefix}/sign-out?returnTo=/path clears the session cookie; refuses another site's post
GET {prefix}/session the profile as JSON (tokens never included), or 401

Middleware puts the profile in the request context; RequireUser also refuses anyone without one (a browser navigation is redirected to sign in, anything else gets 401). Read it with CurrentProfile(r) or ProfileFrom(ctx). Profile.ID() is provider:subject, the stable key for your own data.

Cookies

__Host-authgate_session and __Host-authgate_flow: HttpOnly, Secure, Path=/, no Domain, SameSite=Lax. Each is AES-256-GCM sealed under its own purpose, so a flow cookie never opens as a session. A session over 4096 bytes (name, value and attributes) is refused with authgate.cookie_too_large instead of written.

Errors

The declarations: authgate.not_allowed (403), authgate.cookie_too_large (500), authgate.bad_return_to (400), authgate.unknown_provider (404), authgate.cross_site (403), authgate.method_not_allowed (405), plus plugin/oauth's oauth.state_invalid, oauth.denied and oauth.provider_failed, and auth.signed_out (401).

Documentation

Overview

Package authgate is a login gate, not user accounts: sign in with any OAuth 2.0 or OpenID Connect provider and keep the person's profile in a sealed cookie, with no database, no user table and no auth.New.

It is for an internal tool behind company login, a prototype, a tool that acts on the person's GitHub or Google account, or a serverless function with nowhere to keep rows. Everything the rest of this library stores (users, identities, sessions, devices) does not exist here, and so neither does anything built on it: no per-device sign-out, no passwords, no bans, no admin, no linking of two providers to one person. Revocation is coarse on purpose: every cookie carries an absolute expiry and the SessionVersion it was issued under, so bumping the version (or rotating the secret without keeping the old one) signs everyone out at once. An app that needs any of the missing parts uses auth.New with plugin/oauth instead, and the providers it configured here carry over unchanged.

The providers are plugin/oauth's own (oauth.Google, oauth.GitHub, the presets, oauth.OIDC with discovery), so PKCE, state, the RFC 9207 issuer check and ID-token verification are the code the full library runs. The state is bound to the browser by a sealed __Host- flow cookie, the same login-CSRF defence authhttp's flow cookie gives, without a stored row.

Index

Constants

View Source
const (
	// SessionCookieName holds the sealed profile.
	SessionCookieName = "__Host-authgate_session"
	// FlowCookieName holds the sealed state, PKCE verifier and return path
	// of one sign-in between start and callback.
	FlowCookieName = "__Host-authgate_flow"
)

Cookie names. __Host- makes the browser refuse the cookie unless it is Secure, has Path=/ and no Domain, so a sibling subdomain cannot plant or overwrite it. They differ from authhttp's so a gate and a full Auth can run side by side on one host.

View Source
const (
	// DefaultPrefix is where Handler expects to be mounted.
	DefaultPrefix = "/auth"
	// DefaultSessionLifetime is the absolute lifetime of a sign-in: a week,
	// better-auth's default. It is never extended; the person signs in again
	// after it, which is also when an Allow rule change or a changed provider
	// profile reaches the cookie.
	DefaultSessionLifetime = 7 * 24 * time.Hour
	// FlowLifetime bounds the time a person may spend at the provider,
	// plugin/oauth's DefaultStateTTL.
	FlowLifetime = oauth.DefaultStateTTL
	// MaxCookieSize is the largest Set-Cookie value (name, value and
	// attributes) the gate writes: RFC 6265 section 6.1 asks browsers to
	// accept at least 4096 bytes per cookie, and most stop there. A session
	// that would not fit (provider tokens kept with KeepTokens are the usual
	// cause) is refused with ErrCookieTooLarge rather than written and
	// silently dropped by the browser.
	MaxCookieSize = 4096
	// MaxReturnToLength bounds the returnTo path a start accepts, so the flow
	// cookie that carries it stays far below MaxCookieSize.
	MaxReturnToLength = 1024
)

Defaults and limits.

Variables

View Source
var (
	// ErrNotAllowed is a sign-in or a cookie whose profile no Allow rule
	// accepts.
	ErrNotAllowed = auth.NewError("authgate.not_allowed", http.StatusForbidden, "This account is not allowed here.")
	// ErrCookieTooLarge is a sign-in whose sealed session would exceed
	// MaxCookieSize. Nothing is written; the log says how large it was.
	ErrCookieTooLarge = auth.NewError("authgate.cookie_too_large", http.StatusInternalServerError, "The sign-in could not be saved. Contact the site's owner.")
	// ErrBadReturnTo is a returnTo that is not a path on this site.
	ErrBadReturnTo = auth.NewError("authgate.bad_return_to", http.StatusBadRequest, "The return address must be a path on this site.")
	// ErrUnknownProvider is a route naming a provider the gate was not given.
	ErrUnknownProvider = auth.NewError("authgate.unknown_provider", http.StatusNotFound, "This sign-in method is not offered here.")
	// ErrCrossSite is a sign-out posted from another site.
	ErrCrossSite = auth.NewError("authgate.cross_site", http.StatusForbidden, "This request came from another site.")
)

Errors a request can answer. The state, denial and provider failures reuse plugin/oauth's, so a client sees the same codes either way.

View Source
var (
	// ErrNoProviders is New without WithProviders.
	ErrNoProviders = errors.New("authgate: no providers; pass WithProviders")
	// ErrNoRule is New without any Allow rule. Letting every account at the
	// provider in is a real choice for a GitHub tool and a breach for a
	// company dashboard, so it must be said out loud with AllowAnyone.
	ErrNoRule = errors.New("authgate: no Allow rule; pass Allow, AllowEmailDomain, AllowEmails or AllowAnyone")
	// ErrBaseURL is a missing or malformed WithBaseURL: an http or https
	// origin with no path, query or fragment.
	ErrBaseURL = errors.New("authgate: WithBaseURL must be an http or https origin such as https://tools.example.com")
	// ErrDuplicateProvider is two providers with one name, which would share
	// one callback route.
	ErrDuplicateProvider = errors.New("authgate: two providers share a name")
	// ErrBadOption is an option given an unusable value (a non-positive
	// lifetime, an empty domain, a path that is not one).
	ErrBadOption = errors.New("authgate: bad option")
)

Configuration errors New returns.

Functions

This section is empty.

Types

type Gate

type Gate struct {
	// contains filtered or unexported fields
}

Gate is a configured login gate. It is safe for concurrent use and holds no state beyond its configuration: every sign-in lives in the browser's cookies, so any number of instances with the same options serve the same people.

func New

func New(opts ...Option) (*Gate, error)

New builds a gate. It refuses a configuration nobody could sign in with or that would let in more than intended: no provider, a provider oauth.ValidateProvider refuses, no secret or a short one, no base URL, and no Allow rule.

func (*Gate) CallbackURL

func (g *Gate) CallbackURL(name string) string

CallbackURL is the redirect URI to register at the provider named name.

func (*Gate) Handler

func (g *Gate) Handler() http.Handler

Handler serves the gate's routes under the prefix:

GET  {prefix}/{provider}/start?returnTo=/path   begin a sign-in
GET  {prefix}/{provider}/callback               the provider's redirect back
POST {prefix}/{provider}/callback               a form-post provider's answer, relayed to GET
POST {prefix}/sign-out                          clear the session cookie (?returnTo=/path)
GET  {prefix}/session                           the signed-in Profile as JSON, or 401

Mount it at the prefix with the trailing slash: mux.Handle("/auth/", g.Handler()).

func (*Gate) Middleware

func (g *Gate) Middleware(next http.Handler) http.Handler

Middleware reads the session cookie and, when it opens, carries the current SessionVersion, has not expired and an Allow rule still accepts it, puts the Profile in the request context (ProfileFrom). A cookie that fails any check is cleared. It never refuses a request; RequireUser does.

func (*Gate) Providers

func (g *Gate) Providers() []string

Providers lists the configured provider names in the order given.

func (*Gate) RequireUser

func (g *Gate) RequireUser(next http.Handler) http.Handler

RequireUser serves next only to a signed-in, allowed person. Anyone else gets, for a browser navigation (GET or HEAD accepting text/html), a redirect to SignInPage or, with one provider, straight to its start, with returnTo the page asked for; for anything else, 401 auth.ErrSignedOut. It runs Middleware itself, so it needs nothing in front of it.

func (*Gate) StartURL

func (g *Gate) StartURL(name, returnTo string) string

StartURL is the path that begins a sign-in with the provider named name, returning to returnTo ("" for the site root) afterwards: the href of a "Sign in with …" link.

type Option

type Option func(*Gate)

Option configures a Gate.

func Allow

func Allow(rule func(Profile) bool) Option

Allow adds an access rule. A profile gets in when any rule returns true, so AllowEmailDomain("example.com") and Allow(isContractor) together let in the company and the contractor. Rules run at sign-in and again on every request that carries a cookie, so removing someone from a rule and deploying shuts them out at once, without waiting for their cookie to expire. A rule must therefore be cheap and must not call the network; it sees only what the cookie holds (the Profile, with Tokens under KeepTokens). Without any rule New fails with ErrNoRule.

func AllowAnyone

func AllowAnyone() Option

AllowAnyone lets in every account the providers sign in: the explicit choice for a tool that acts on the person's own account and guards nothing of the operator's. Any other rule is then redundant.

func AllowEmailDomain

func AllowEmailDomain(domains ...string) Option

AllowEmailDomain lets in a profile whose email the provider verified and whose domain is one of domains, compared without regard to case. The match is exact: "example.com" does not admit "eu.example.com" (list it too), so a subdomain someone else controls is never let in by accident. An unverified address never matches: anyone can type any address into an account at a provider that does not check it.

func AllowEmails

func AllowEmails(addrs ...string) Option

AllowEmails lets in a profile whose email the provider verified and is one of addrs, compared without regard to case.

func ErrorHandler

func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request, err error)) Option

ErrorHandler answers every error of Handler and RequireUser's 401 instead of the library's JSON error body, for an app that renders its own page. err is one of this package's errors, auth.ErrSignedOut, or plugin/oauth's ErrStateInvalid, ErrProviderDenied or ErrProviderFailed; auth.ErrorOf(err) gives its code and status.

func KeepTokens

func KeepTokens() Option

KeepTokens keeps the provider's access token, refresh token, expiry and granted scope in the sealed session cookie, for a tool that calls the provider's API as the person (Profile.Tokens). Off by default: a cookie that holds nothing but a profile leaks nothing an attacker can use at the provider. The ID token and the raw token answer are never kept. Tokens are what the provider issued at sign-in; nothing refreshes them into the cookie, so a tool whose access token expires before the session does asks the person to sign in again. Large tokens (Entra ID's can pass 2 KB) can push the cookie over MaxCookieSize, which refuses the sign-in with ErrCookieTooLarge.

func Prefix

func Prefix(path string) Option

Prefix sets the path Handler is mounted at; the default is DefaultPrefix.

func SessionLifetime

func SessionLifetime(d time.Duration) Option

SessionLifetime sets how long a sign-in lasts from the moment it happened; the default is DefaultSessionLifetime. It is absolute: activity does not extend it.

func SessionVersion

func SessionVersion(n uint64) Option

SessionVersion stamps every cookie with n and accepts only cookies stamped with n. Raising it (from a deploy variable) signs everyone out at once: the gate's only revocation, because there is no store to delete one session from. The default is 0.

func SignInPage

func SignInPage(path string) Option

SignInPage is where RequireUser sends a browser that is not signed in, with ?returnTo= the page it asked for. Without it, a gate with exactly one provider sends the browser straight to that provider's start, and a gate with several answers 401.

func WithBaseURL

func WithBaseURL(origin string) Option

WithBaseURL sets the origin the gate is served at, such as "https://tools.example.com". The callback URL registered at each provider is this origin, the prefix, then /{provider}/callback. http is accepted for local development on localhost; the cookies stay Secure, which browsers honour on http://localhost.

func WithClock

func WithClock(now func() time.Time) Option

WithClock replaces time.Now. Tests move time with it.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient sets the client used to reach the providers; the default times out after oauth.DefaultHTTPTimeout.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets where refused sign-ins and provider failures are logged; the default is slog.Default.

func WithProviders

func WithProviders(ps ...oauth.Provider) Option

WithProviders sets the providers people may sign in with: any oauth.Provider, such as oauth.Google, oauth.GitHub, a preset or oauth.OIDC. Each is checked by oauth.ValidateProvider, the check plugin/oauth runs.

func WithSecret

func WithSecret(current []byte, previous ...[]byte) Option

WithSecret sets the secret the cookie keys are derived from, by the same HKDF rule as auth.WithSecret under the label auth.KeyLabelAuthGate. It is required and at least auth.MinSecretSize bytes. Rotation: pass the new secret first and the old ones after; cookies sealed under an old one still open until they expire. Dropping a secret signs out everyone it sealed.

type Profile

type Profile struct {
	// Provider is the name of the provider signed in with, such as "google".
	Provider string `json:"provider"`
	// Subject is the provider's stable id for the person, never the email.
	Subject string `json:"subject"`
	// Email is the address the provider gave, "" when it gave none.
	Email string `json:"email,omitempty"`
	// EmailVerified is the provider's claim that the person controls Email.
	// AllowEmailDomain and AllowEmails admit only a verified address.
	EmailVerified bool   `json:"emailVerified"`
	Name          string `json:"name,omitempty"`
	Image         string `json:"image,omitempty"`
	// SignedInAt is when the sign-in happened; ExpiresAt is SignedInAt plus
	// SessionLifetime, after which the cookie is refused.
	SignedInAt time.Time `json:"signedInAt"`
	ExpiresAt  time.Time `json:"expiresAt"`
	// Tokens are the provider's tokens under KeepTokens: AccessToken,
	// RefreshToken, ExpiresAt, RefreshExpiresAt and Scope. Zero without
	// KeepTokens. Never written to JSON; the tokens are auth.Token, which
	// prints as "[redacted]" anyway.
	Tokens oauth.TokenSet `json:"-"`
}

Profile is the signed-in person as the provider described them at sign-in, read back from the sealed session cookie. There is no user id of the gate's own: ID (provider and subject) is the stable key an app keys its own data by.

func CurrentProfile

func CurrentProfile(r *http.Request) (Profile, bool)

CurrentProfile is ProfileFrom(r.Context()).

func ProfileFrom

func ProfileFrom(ctx context.Context) (Profile, bool)

ProfileFrom returns the signed-in Profile Middleware or RequireUser put in ctx, and false when the request carried no valid, allowed, unexpired session cookie.

func (Profile) ID

func (p Profile) ID() string

ID is Provider and Subject joined by a colon: unique per person per provider, and stable when the person changes their email or name.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL