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 ¶
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 )
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.
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 ¶
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") )
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.
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 ¶
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 ¶
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 ¶
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 ¶
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. |