gobrowserlogin

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 2026 License: MIT Imports: 19 Imported by: 0

README

gobrowserlogin

OIDC login for the browser front end of a Go web API. The API is a confidential client of the provider and keeps the session on the server. The browser holds one opaque cookie, and no token ever reaches JavaScript.

This is the backend-for-frontend pattern of RFC 10017, the OAuth best current practice for browser-based applications. The RFC recommends it above every other pattern for an application holding personal data, because script running in the page has no token to steal. It can act only while the page is open, through the same requests the page makes.

The OAuth protocol is golang.org/x/oauth2's, and ID token verification is github.com/coreos/go-oidc's. This package adds what neither holds:

  • the login attempt, bound to the browser that started it
  • pushed authorization requests
  • the RFC 9207 issuer check
  • the session store, with idle and absolute lifetimes
  • the cookie and CSRF policy RFC 10017 sets

It is a library with no main package.

Install

go get github.com/datapointchris/gobrowserlogin@latest   # a tagged module; there is no binary

Starting from scratch

An API serving its own single-page front end on one origin mounts three routes and calls Authenticate from the middleware guarding its API routes:

db := stdlib.OpenDBFromPool(pool) // any database/sql PostgreSQL driver
store, err := postgresstore.New(ctx, db) // refuses a database without postgresstore.Schema's tables

sessions, err := gobrowserlogin.New(ctx, gobrowserlogin.Config{
    Issuer:       "https://auth.example.com",
    ClientID:     "myapp-web",
    ClientSecret: os.Getenv("MYAPP_OIDC_CLIENT_SECRET"), // wherever the API keeps its secrets
    RedirectURL:  "https://myapp.example.com/api/auth/callback",
    Scopes:       []string{"openid", "profile", "groups"},
    Store:        store,
})

mux.HandleFunc("GET /api/auth/login", sessions.Login)       // a navigation, never a fetch
mux.HandleFunc("GET /api/auth/callback", sessions.Callback) // RedirectURL's path
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
    switch err := sessions.Logout(w, r); {
    case errors.Is(err, gobrowserlogin.ErrNoCSRFHeader):
        w.WriteHeader(http.StatusForbidden)
    case err != nil:
        w.WriteHeader(http.StatusServiceUnavailable)
    default:
        w.WriteHeader(http.StatusNoContent)
    }
})

// in the middleware guarding the API's routes, which the front end's script calls
identity, err := sessions.Authenticate(r)
switch {
case errors.Is(err, gobrowserlogin.ErrNoSession):    // 401: the front end sends the browser to log in
case errors.Is(err, gobrowserlogin.ErrNoCSRFHeader): // 403: a forged request, or a front end missing the header
case err != nil:                                     // 503: the store failed
}

Authenticate authenticates a script's request, which carries the CSRF header. A navigation cannot carry it. So the login and callback routes, the front end's own files, and every route the browser navigates to sit outside that middleware.

The same holds for anything a page loads by URL rather than by script. An <img src>, a download link, an EventSource and a WebSocket upgrade each send the session cookie without the header, and Authenticate refuses them with ErrNoCSRFHeader. A resource that needs the session is read with fetch, and the page shows it from there, through URL.createObjectURL for an image or a download.

The front end sends the CSRF header on every request. A read that gets a 401 navigates to the login route with the page it was on. A write that gets a 401 fails where it was made instead. Navigating would leave its caller unsettled, so the edit would be lost with no notice.

let loggingIn = false

async function api(path, init = {}) {
  const resp = await fetch(path, { ...init, headers: { ...init.headers, 'X-CSRF': '1' } })
  if (resp.status !== 401) return resp
  if ((init.method ?? 'GET').toUpperCase() !== 'GET') {
    throw new Error('Your login has ended, so this change was not saved.')
  }
  if (!loggingIn) {
    loggingIn = true
    const here = location.pathname + location.search + location.hash
    location.assign('/api/auth/login?return_to=' + encodeURIComponent(here))
  }
  return new Promise(() => {}) // the page is leaving, so nothing renders an error
}

A page's reads all get the 401 together, and loggingIn makes them one login. A login that completes while the API still answers 401, as with cookies blocked, would send the browser round the provider forever. So count the login starts in sessionStorage, and fail the read once a third page starts one inside a minute. Allow two, because the refused callback's link below starts a second.

The provider needs the API registered as a confidential client:

  • redirect URI exactly RedirectURL
  • grant type authorization_code, response type code
  • token endpoint authentication client_secret_basic
  • PKCE with S256
  • the scopes in Config.Scopes

Where the provider supports them, also require pushed authorization requests for this client, and let a first-party client skip the consent screen. Otherwise every login asks the user to approve their own application.

Which function answers which question

Ask Function Reads
How does a browser log in Login, then Callback the provider's discovery document, its pushed request, token and userinfo endpoints, and the Store
Who sent this request Authenticate the session cookie, the CSRF header, and the Store
How does a browser log out Logout the session cookie, the CSRF header, and the Store
Where do sessions live Store, postgresstore.New PostgreSQL, through database/sql
What tables does that need postgresstore.Schema —

Login and Callback — a code completes one login, in the browser that asked for it

Login records the attempt in the Store under a hash of its OAuth state:

  • the nonce the ID token must carry back
  • the PKCE verifier
  • the return_to path

It sets a cookie naming the state and sends the browser to the provider. Each attempt has its own cookie, so two tabs logging in at once do not overwrite each other's.

Callback refuses an answer whose state this browser holds no cookie for. A code obtained in an attacker's browser therefore cannot log a victim's browser in to the attacker's account. The attempt is deleted as it is read, so each code completes one login at most. An attempt older than ten minutes is refused.

return_to must be a path on this site. A full URL, //host, a backslash or a control character is refused before anything is recorded. No login link on this site can land its follower on another one.

Pushed authorization requests. Where the provider's discovery document advertises an RFC 9126 endpoint, Login posts the authorization request there, authenticated with the client secret. The browser carries only the reference it gets back. The request's parameters never appear in the browser's history, and the provider knows the request came from this client before any user sees it.

Issuer check. Where the provider promises RFC 9207 issuer identification, Callback refuses an answer naming another issuer, or none. That closes the mix-up attack, where an answer from one provider is passed off as another's.

Redemption. The code is redeemed with the client secret and the PKCE verifier. The ID token's signature, issuer, audience, expiry and nonce are checked. The identity is decoded from the ID token's claims and then from the userinfo response, so a claim either one carries is kept. A userinfo response naming another subject is refused.

A new session is created under a new secret, so a cookie planted before the login names nothing after it. Where the provider shares this site, the browser also sends the session it held, and that session is deleted.

A refused answer links onward. A refusal spends the attempt, and a reload of the callback sends the same code again. So the refusal page links to the attempt's return_to path, or to / where no attempt was read. The front end there answers its next 401 by starting a new login.

The session cookie is __Host-Http-session:

  • Secure and HttpOnly
  • SameSite=Strict
  • Path=/, with no Domain

That is what RFC 10017 § 6.1.3.2 asks for. __Host- makes the browser refuse the cookie unless it carries those attributes, so a sibling subdomain cannot plant or overwrite it. Http- makes a browser that knows the prefix refuse it from script too. Its value is 128 random bits. The Store keys the session by the value's SHA-256, so a copy of the store names no cookie anyone could present.

CSRF. Every request carrying the cookie must also carry X-CSRF: 1 (CSRFHeader, CSRFHeaderValue). A page on another origin cannot add a custom header without a CORS preflight. An API that answers no preflight approves none, so the browser never sends the forged request. SameSite=Strict alone falls short when the API shares its site with other applications, as RFC 10017 § 6.1.3.3 notes: a sibling subdomain is same-site, and its requests carry the cookie.

Lifetimes. A session ends after IdleTimeout with no request, two hours by default. It also ends AbsoluteTimeout after its login however busy it is, twelve hours by default. Both are checked against the configuration on every request, so a shortened timeout reaches sessions already stored. A request moves the idle deadline, writing the store at most once a minute.

The absolute timeout is what sends a user back to the provider. A user disabled there keeps a session here until it runs out. Where the provider still holds its own session, the next login completes without the user doing anything.

Logout — this API's session ends, and the provider's goes on

Logout deletes the session and clears the cookie. Serve it on POST. It requires the CSRF header like any other request, so a cross-site form cannot log anyone out. The response is the caller's to write.

It does not end the user's session at the provider. Where the provider still holds one, the next login completes without asking anything. Ending that session means sending the browser to the provider's own logout page.

postgresstore — two tables, created by the application's migrations

postgresstore.Schema creates browser_login_attempts and browser_sessions. Copy it into a migration. postgresstore.New checks both tables and every column it uses, so a database missing them fails at startup rather than at the first login.

Login deletes expired attempts and sessions each time it runs. A login is rare, so the tables stay small without the application scheduling anything.

The store reaches PostgreSQL through database/sql. An application on pgx shares its pool with stdlib.OpenDBFromPool.

What it deliberately does not do

It keeps no tokens. The session holds the identity the provider stated at login, and the access and refresh tokens are dropped once the userinfo response is read. An API answering its own front end needs nothing else, and a refresh token kept at rest is a credential nobody uses. The cost is that a user disabled at the provider keeps a session here until its absolute timeout. An API that calls another API on the user's behalf needs the tokens, and that is the requirement that would add them.

It does not do RP-initiated, front-channel or back-channel logout. It does not do DPoP or mTLS-bound tokens either. Each needs the tokens it does not keep, or a provider capability not every provider has.

It is not a general OIDC client. zitadel/oidc is one, and is OpenID Foundation certified.

Documentation

Overview

Package gobrowserlogin logs a browser into a Go web API through an OIDC provider, with the API holding the session.

It is the backend-for-frontend pattern of RFC 10017, the OAuth 2.0 best current practice for browser-based applications, for an API that serves its own single-page front end. The API is a confidential client of the provider. It runs the authorization code flow with PKCE, keeps the resulting session in a server-side store, and gives the browser nothing but an opaque cookie. No token ever reaches JavaScript, so script running in the page has nothing to steal and can act only while the page is open.

The OAuth protocol is golang.org/x/oauth2's, and ID token verification is github.com/coreos/go-oidc's. This package adds what neither holds: the login attempt bound to the browser that started it, pushed authorization requests, the RFC 9207 issuer check, the session store with idle and absolute lifetimes, and the cookie and CSRF policy RFC 10017 sets.

Requests

A request is authenticated by Sessions.Authenticate, which reads the session cookie and requires the static header CSRFHeader on every request carrying it. A cross-origin page cannot add that header without a CORS preflight the API never approves, so a forged request is refused even from a sibling subdomain, where SameSite does not apply. A navigation cannot carry the header either, so the front end's files and every route a browser navigates to are served without Authenticate.

Index

Constants

View Source
const (
	// DefaultIdleTimeout ends a session no request has used for two hours.
	DefaultIdleTimeout = 2 * time.Hour

	// DefaultAbsoluteTimeout ends a session twelve hours after its login,
	// however busy it is. A login after that goes back to the provider, which
	// is what notices a user who was disabled there.
	DefaultAbsoluteTimeout = 12 * time.Hour
)
View Source
const (
	CSRFHeader      = "X-CSRF"
	CSRFHeaderValue = "1"
)

CSRFHeader is the header every request carrying the session cookie sends, with the value CSRFHeaderValue. A page on another origin cannot add a custom header without a CORS preflight, and an API that answers no preflight approves none, so the browser never sends the forged request. That holds for a sibling subdomain too, which is same-site and so passes SameSite. RFC 10017 § 6.1.3.3 leaves the name to the application.

View Source
const ReturnToParameter = "return_to"

ReturnToParameter is the query parameter naming where Login sends the browser once the login completes: a path on this site, such as the page that got the 401. Absent means "/".

Variables

View Source
var (
	// ErrNoSession means the request carries no session cookie, or one
	// naming no live session. The API answers 401, and the front end sends
	// the browser to the login route.
	ErrNoSession = errors.New("gobrowserlogin: no session")

	// ErrNoCSRFHeader means the request carries the session cookie without
	// CSRFHeader. The API answers 403. A front end that sends the header on
	// every request never sees it, so it marks a forged request or a front
	// end missing the header.
	ErrNoCSRFHeader = errors.New("gobrowserlogin: the request carries the session cookie without the " + CSRFHeader + " header")
)
View Source
var DefaultScopes = []string{"openid", "profile"}

DefaultScopes are what a login asks for when Config.Scopes is empty. openid makes it an OIDC login, and profile yields the username and display name an Identity carries.

View Source
var ErrNotFound = errors.New("gobrowserlogin: not in the store")

ErrNotFound is what a Store returns for a key it does not hold.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Issuer is the OIDC provider's base URL. Discovery hangs off it.
	Issuer string

	// ClientID and ClientSecret are the API's registration at the provider,
	// as a confidential client authenticating with HTTP Basic
	// (client_secret_basic). RFC 10017 § 6.1.3.1 requires a confidential
	// client: the secret is what stops anyone else redeeming a code issued
	// to this API.
	ClientID     string
	ClientSecret string

	// RedirectURL is where the provider sends the browser after login, and
	// where Sessions.Callback is served. It must be registered at the
	// provider exactly. It is https, or http on a loopback host, which
	// browsers treat as secure and keep secure cookies for.
	RedirectURL string

	// Scopes are requested at login. Empty means DefaultScopes. openid is
	// required. Add groups where the provider has that scope and the API
	// reads Identity.Groups.
	Scopes []string

	// Store keeps login attempts and sessions.
	Store Store

	// IdleTimeout ends a session no request has used for this long. Zero
	// means DefaultIdleTimeout.
	IdleTimeout time.Duration

	// AbsoluteTimeout ends a session this long after its login. Zero means
	// DefaultAbsoluteTimeout. It is also the session cookie's lifetime.
	AbsoluteTimeout time.Duration

	// HTTPClient makes every request to the provider. Nil means a client
	// with a ten-second timeout. A client with no Timeout is refused,
	// because go-oidc fetches the provider's signing keys under no other
	// deadline. A provider behind a bot filter may refuse Go's default
	// User-Agent, which the client's Transport can replace.
	HTTPClient *http.Client

	// Logger receives each refused login with its reason, which the browser
	// is not shown. Nil means slog.Default().
	Logger *slog.Logger
}

Config is one web API's login for its browser front end, at one provider. Issuer, ClientID, ClientSecret, RedirectURL and Store are required. Every other field has a documented default.

type Identity

type Identity struct {
	// Subject is the provider's stable identifier for the user.
	Subject string `json:"sub"`

	// Username is the preferred_username claim, from the profile scope.
	Username string `json:"preferred_username,omitempty"`

	// Name is the display name, from the profile scope.
	Name string `json:"name,omitempty"`

	// Groups is the groups claim, which most providers grant through a
	// groups scope.
	Groups []string `json:"groups,omitempty"`
}

Identity is who a session belongs to, as the provider stated at login. It is decoded from the ID token's claims and then the userinfo response's, so a claim either one carries is kept. A claim the granted scopes did not cover is empty.

type Key

type Key [sha256.Size]byte

Key names a stored login attempt or session. It is the SHA-256 of the secret the browser holds: the OAuth state for an attempt, the cookie's value for a session. A copy of a store therefore names nothing a reader could present as a browser.

type LoginAttempt

type LoginAttempt struct {
	// Nonce is the value the ID token must carry back.
	Nonce string

	// CodeVerifier is the PKCE secret the token request proves the code
	// with.
	CodeVerifier string

	// ReturnTo is the path on this site the browser goes to after login.
	ReturnTo string

	// ExpiresAt is ten minutes after the attempt began.
	ExpiresAt time.Time
}

LoginAttempt is a login a browser has started and not finished.

type Session

type Session struct {
	Identity Identity

	// CreatedAt is when the login completed. The absolute timeout runs from
	// it.
	CreatedAt time.Time

	// SeenAt is when a request last used the session, to within a minute.
	// The idle timeout runs from it.
	SeenAt time.Time

	// ExpiresAt is the earlier of the two timeouts, as configured when the
	// session was last written. A store may delete the session after it.
	// Sessions checks both timeouts against its own configuration on every
	// request, so a shortened timeout applies to sessions already stored.
	ExpiresAt time.Time
}

Session is one browser's login, as a Store keeps it.

type Sessions

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

Sessions logs browsers in through one provider and authenticates their requests. Mount Login, Callback and Logout as routes, and call Authenticate from the API's own authentication middleware.

func New

func New(ctx context.Context, cfg Config) (*Sessions, error)

New reads the provider's discovery document and returns the Sessions for cfg. It fails where the document cannot be read, or names an issuer other than cfg.Issuer.

func (*Sessions) Authenticate

func (s *Sessions) Authenticate(r *http.Request) (Identity, error)

Authenticate returns the identity of the session the request's cookie names. It returns ErrNoSession where there is none, and ErrNoCSRFHeader where the cookie arrives without CSRFHeader. Any other error is the store failing, which says nothing about the browser.

It authenticates a script's request. A navigation, and anything a page loads by URL, sends the cookie without the header and is refused. So the front end's files and the routes a browser navigates to are served without calling it.

A session unused for IdleTimeout, or older than AbsoluteTimeout, has ended. A request on a live session moves its idle deadline, writing the store at most once a minute.

func (*Sessions) Callback

func (s *Sessions) Callback(w http.ResponseWriter, r *http.Request)

Callback completes a login. Serve it on GET at Config.RedirectURL's path.

It refuses an answer whose state this browser holds no cookie for, so a code obtained in one browser cannot log another one in. The attempt is deleted as it is read, so each code completes one login at most. Where the provider promises RFC 9207 issuer identification, an answer naming another issuer, or none, is refused. The code is redeemed with the client secret and the PKCE verifier, the ID token's signature, audience, expiry and nonce are checked, and the userinfo response must name the same subject.

A new session is created under a new secret, so a cookie planted before the login names nothing after it. Where the provider shares this site, the browser also sends the session it held, and that session is deleted. The browser then goes to the attempt's return_to path.

A refused answer cannot be retried: the attempt is spent, and a reload sends the same code again. The refusal page links to the attempt's return_to path instead, or to / where no attempt was read. The front end there answers its next 401 by starting a new login.

func (*Sessions) Login

func (s *Sessions) Login(w http.ResponseWriter, r *http.Request)

Login starts a login and sends the browser to the provider. Serve it on GET, and send the browser to it with a navigation rather than a fetch, since it answers with a redirect off this site.

The attempt is recorded in the Store under its OAuth state, with the nonce the ID token must carry back and the PKCE verifier the token request proves the code with. A cookie naming the state binds the attempt to this browser, so a callback carrying another browser's state is refused. Where the provider advertises RFC 9126 pushed authorization requests, the request travels to the provider directly, authenticated with the client secret, and the browser carries only a reference to it.

func (*Sessions) Logout

func (s *Sessions) Logout(w http.ResponseWriter, r *http.Request) error

Logout ends the session the request's cookie names and clears the cookie. Serve it on POST. It returns ErrNoCSRFHeader where the header is missing, and does nothing then, so a cross-site form cannot log anyone out. A request with no session is already logged out and returns nil. The caller writes the response.

It ends this API's session alone. The browser's session at the provider goes on, so the next login completes without asking the user anything.

type Store

type Store interface {
	// SaveLoginAttempt records an attempt under a key no other attempt
	// holds.
	SaveLoginAttempt(ctx context.Context, key Key, attempt LoginAttempt) error

	// TakeLoginAttempt returns the attempt and deletes it in one step, so an
	// attempt completes one login at most. It returns ErrNotFound where key
	// holds none.
	TakeLoginAttempt(ctx context.Context, key Key) (LoginAttempt, error)

	// SaveSession records a session under a key no other session holds.
	SaveSession(ctx context.Context, key Key, session Session) error

	// LoadSession returns ErrNotFound where key holds none.
	LoadSession(ctx context.Context, key Key) (Session, error)

	// TouchSession sets a session's SeenAt and ExpiresAt. A key holding none
	// is not an error.
	TouchSession(ctx context.Context, key Key, seenAt, expiresAt time.Time) error

	// DeleteSession removes a session. A key holding none is not an error.
	DeleteSession(ctx context.Context, key Key) error

	// DeleteExpired removes every attempt and session whose ExpiresAt is not
	// after now.
	DeleteExpired(ctx context.Context, now time.Time) error
}

Store keeps login attempts and sessions on the server, where script in the page cannot reach them. postgresstore is the implementation for PostgreSQL. Every method is called concurrently.

Directories

Path Synopsis
Package postgresstore keeps gobrowserlogin's login attempts and sessions in PostgreSQL, through database/sql and whichever driver the application already uses.
Package postgresstore keeps gobrowserlogin's login attempts and sessions in PostgreSQL, through database/sql and whichever driver the application already uses.

Jump to

Keyboard shortcuts

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