Help us shape the future of Go! Take the 2026 Go Developer Survey

sociable

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 22 Imported by: 0

README

sociable

Authenticate with Google, GitHub, GitLab, Facebook, LinkedIn, Bitbucket, Slack, Twitch, X, or any OAuth 2 or OpenID Connect provider, on net/http. You get back who the person is and a token to use.

sociable.Configure(sociable.Config{
    Key: os.Getenv("APP_KEY"),
    Drivers: sociable.Drivers{
        "github": {ClientID: id, ClientSecret: secret, Redirect: "/auth/github/callback"},
        "google": {ClientID: id, ClientSecret: secret, Redirect: "/auth/google/callback"},
    },
})
http.HandleFunc("/auth/github", func(w http.ResponseWriter, r *http.Request) {
    sociable.Driver("github").Redirect(w, r)
})

http.HandleFunc("/auth/github/callback", func(w http.ResponseWriter, r *http.Request) {
    user, err := sociable.Driver("github").User(w, r)
    if err != nil {
        http.Error(w, "sign-in failed", http.StatusBadRequest)
        return
    }

    user.ID, user.Nickname, user.Name, user.Email, user.EmailVerified, user.Avatar
    user.Token.AccessToken, user.Token.RefreshToken, user.Token.Expiry
    user.ApprovedScopes
    user.Raw
})

Configuration

Field
Key encrypts the cookie that carries the handshake between redirect and callback. Any string.
URL the application's own URL, for a Redirect given as a path.
Drivers a name to its ClientID, ClientSecret, Redirect and optional Scopes.
Session an application's own session, instead of the cookie. Key is then not needed.
Client the HTTP client used to talk to providers.

Redirect is a full URL, or a path resolved against URL.

Shaping the redirect

All optional, all chainable:

sociable.Driver("google").
    Scopes("https://www.googleapis.com/auth/drive.readonly").   // adds to the driver's
    With(map[string]string{"access_type": "offline", "prompt": "consent"}).
    Redirect(w, r)

sociable.Driver("google").SetScopes("openid", "email").Redirect(w, r)  // replaces them
sociable.Driver("github").RedirectURL("https://other.example/cb").Redirect(w, r)

WithState carries values through the flow. They come back on user.State at the callback and never travel through the provider:

sociable.Driver("github").WithState(map[string]string{"invite": code}).Redirect(w, r)

user, _ := sociable.Driver("github").User(w, r)
user.State["invite"]

AuthURL(w, r) is Redirect without the redirecting, for a handler that sends the browser itself.

Tokens

user, err := sociable.Driver("github").UserFromToken(ctx, token)          // a token you already hold
token, err := sociable.Driver("google").RefreshToken(ctx, refreshToken)   // a live one; persist what comes back
client := sociable.Driver("google").Client(ctx, token)                    // an *http.Client that presents and refreshes it

Stateless() runs a callback this server did not start, for a native app that brings its own code.

Errors

user, err := sociable.Driver("github").User(w, r)

switch {
case errors.Is(err, sociable.ErrAccessDenied):   // the person said no
case errors.Is(err, sociable.ErrInvalidState):   // not started here, already used, or expired
case errors.Is(err, sociable.ErrUnknownDriver):
case errors.Is(err, sociable.ErrNotConfigured):
case err != nil:                                 // the provider refused, the exchange failed, the id token did not verify
}

Every provider refusal matches ErrAuthorization and carries the provider's words as an *AuthorizationError. A failed ID token is ErrIDToken, a failed exchange ErrExchange, a profile that would not load ErrProfile.

Your own provider

Any OpenID Connect provider is its issuer:

sociable.Extend("okta", func(c sociable.Credentials) sociable.Provider {
    return sociable.OpenID(c, "https://acme.okta.com")
})

Any OAuth 2 provider is its endpoints and a mapping:

sociable.Extend("acme", func(c sociable.Credentials) sociable.Provider {
    return sociable.OAuth2{
        Credentials: c,
        Endpoint:    oauth2.Endpoint{AuthURL: base + "/oauth/authorize", TokenURL: base + "/oauth/token"},
        Scopes:      []string{"profile", "email"},
        ProfileURL:  base + "/oauth/userinfo",
        Map: func(raw map[string]any) sociable.User {
            return sociable.User{ID: sociable.String(raw, "sub"), Name: sociable.String(raw, "name"), Email: sociable.String(raw, "email")}
        },
    }
})

Then it is configured and used like a built-in:

"acme": {ClientID: id, ClientSecret: secret, Redirect: "/auth/acme/callback"},
sociable.Driver("acme").Redirect(w, r)

A provider with more to read than one document implements User itself. It gets an *http.Client that already presents the token:

func (self github) User(ctx context.Context, client *http.Client, token *oauth2.Token) (*sociable.User, error) {
    user, err := self.OAuth2.User(ctx, client, token)
    if err != nil || user.Email != "" {
        return user, err
    }

    var emails []email
    if err := sociable.GetJSON(ctx, client, "https://api.github.com/user/emails", &emails); err != nil {
        return nil, err
    }
    // ...
}

A provider that is not OAuth at all, a login widget for instance, implements Redirect(w, r, callback, state) and Callback(r) itself and the flow gets out of the way. The state check still runs.

Tests

Fake swaps a driver for one that returns the user you give it, the way Socialite::fake does. Fields you leave blank get a test person's values, the same ones as Socialite's fake user. Nothing else needs configuring, nothing talks to a provider, and a test drives the callback directly:

fake := sociable.Fake("github", sociable.User{Email: "arjen@example.test"})
t.Cleanup(fake.Restore)

recorder := httptest.NewRecorder()
app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/auth/github/callback", nil))

if recorder.Header().Get("Location") != "/dashboard" {
    t.Fatalf("landed on %s", recorder.Header().Get("Location"))
}

What Fake returns records every redirect the handler asked for, so a test of the redirect side can check how the person was sent off:

fake := sociable.Fake("google", sociable.User{})

app.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/connect/drive", nil))

fake.AssertRedirected(t, func(r sociable.Redirect) bool {
    return slices.Contains(r.Scopes, "https://www.googleapis.com/auth/drive.readonly")
})

AssertNotRedirected, AssertRedirectedCount and Redirects go with it.

What is on by default

PKCE on every request, a nonce and a verified ID token for every OpenID Connect provider, state compared in constant time, a handshake that expires and is spent whether the callback succeeds or not. None of it needs mentioning at the call site.

Built on golang.org/x/oauth2 for the protocol and go-oidc for ID tokens. Every request to a provider, the exchange included, goes through Client, so a traced transport there traces all of it.

Documentation

Overview

Package sociable authenticates people with Google, GitHub, GitLab, Facebook, LinkedIn, Bitbucket, Slack, Twitch, X, or any OAuth 2 or OpenID Connect provider, on net/http. It hands back who they are and a token to use.

sociable.Configure(sociable.Config{
	Key: os.Getenv("APP_KEY"),
	Drivers: sociable.Drivers{
		"github": {ClientID: id, ClientSecret: secret, Redirect: "/auth/github/callback"},
	},
})

sociable.Driver("github").Redirect(w, r)
user, err := sociable.Driver("github").User(w, r)

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrUnknownDriver means no driver is configured under that name.
	ErrUnknownDriver = errors.New("sociable: unknown driver")

	// ErrNotConfigured means the driver has no credentials or no endpoints,
	// because discovery failed. Refuse the route rather than redirect.
	ErrNotConfigured = errors.New("sociable: driver not configured")

	// ErrInvalidState means the callback was not started here, was already
	// used, expired, or came back with another state.
	ErrInvalidState = errors.New("sociable: invalid state")

	// ErrAuthorization is what every provider refusal matches.
	ErrAuthorization = errors.New("sociable: the provider refused")

	// ErrAccessDenied is the refusal that means the person said no.
	ErrAccessDenied = fmt.Errorf("%w: access denied", ErrAuthorization)

	// ErrExchange means the code could not be exchanged for a token, or the
	// token is not usable.
	ErrExchange = errors.New("sociable: token exchange failed")

	// ErrIDToken means the ID token failed verification: signature, issuer,
	// audience, expiry or nonce.
	ErrIDToken = errors.New("sociable: the id token could not be verified")

	// ErrProfile means the provider would not describe the person.
	ErrProfile = errors.New("sociable: could not read the profile")
)
View Source
var ErrNoKey = errors.New("sociable: a Key or a Session is required")

ErrNoKey is returned by New when there is nowhere to keep a handshake.

View Source
var ErrSealed = errors.New("sociable: the cookie cannot be opened")

ErrSealed means a cookie that was forged, truncated or issued under another key. The flow treats it as no handshake.

Functions

func Bool

func Bool(raw map[string]any, path string) bool

Bool reads a boolean at a dotted path.

func Configure

func Configure(config Config)

Configure sets up the default manager. It panics on a configuration that cannot work, because that is a mistake at boot rather than at runtime.

func Extend

func Extend(name string, factory Factory)

Extend registers a provider under a name on the default manager. The factory receives the credentials configured under that name.

func GetJSON

func GetJSON(ctx context.Context, client *http.Client, url string, into any) error

GetJSON fetches a JSON document into any value, with a client that presents the token. A status other than 200 is an error.

func String

func String(raw map[string]any, path string) string

String reads a string at a dotted path, such as "picture.data.url". A number is formatted, so a numeric id reads as one.

Types

type Authenticator

type Authenticator interface {
	Callback(r *http.Request) (*User, error)
}

Authenticator is a provider that reads the person from the callback itself, with no code to exchange.

type AuthorizationError

type AuthorizationError struct {
	Code        string
	Description string
	URI         string
}

AuthorizationError is a refusal the provider sent back on the redirect, in the shape RFC 6749 section 4.1.2.1 defines.

func (*AuthorizationError) Error

func (self *AuthorizationError) Error() string

func (*AuthorizationError) Is

func (self *AuthorizationError) Is(target error) bool

Is makes every refusal match ErrAuthorization, and access_denied match ErrAccessDenied as well.

type Config

type Config struct {
	// Key encrypts the cookie that carries the handshake between the redirect
	// and the callback. Any string; required unless Session is set.
	Key string

	// URL is the application's own, for resolving a relative Redirect.
	URL string

	Drivers Drivers

	// Session holds the handshake instead of the cookie, for an application
	// that has a session already.
	Session Session

	// Client talks to the providers. nil is http.DefaultClient.
	Client *http.Client
}

Config is set once at boot.

type Credentials

type Credentials struct {
	ClientID     string
	ClientSecret string

	// Redirect is the callback URL the provider sends the browser back to. A
	// path is resolved against Config.URL.
	Redirect string

	// Scopes replace the driver's defaults.
	Scopes []string
}

Credentials are what a provider issued the application.

type Drivers

type Drivers map[string]Credentials

Drivers maps a driver name to its credentials. The name picks the built-in provider of that name, or one registered with Extend.

type Factory

type Factory func(credentials Credentials) Provider

Factory builds a Provider from the credentials configured under its name.

type Faked

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

Faked is a faked driver: the user it answers with, and every redirect it was asked for, so a test can check what the handler sent the person off with.

func Fake

func Fake(name string, user User) *Faked

Fake replaces a driver on the default manager with one that returns the given user at the callback, for tests. Nothing needs configuring, and any field left blank gets a test value, so Fake("github", User{}) is a whole person. What comes back records every redirect, for asserting on.

func (*Faked) AssertNotRedirected

func (self *Faked) AssertNotRedirected(t testing.TB, conditions ...func(Redirect) bool)

AssertNotRedirected fails the test if a redirect happened that every condition accepts. With no conditions, any redirect fails it.

func (*Faked) AssertRedirected

func (self *Faked) AssertRedirected(t testing.TB, conditions ...func(Redirect) bool)

AssertRedirected fails the test unless a redirect happened that every condition accepts.

func (*Faked) AssertRedirectedCount

func (self *Faked) AssertRedirectedCount(t testing.TB, count int)

AssertRedirectedCount fails the test unless exactly count redirects happened.

func (*Faked) Redirects

func (self *Faked) Redirects() []Redirect

Redirects are the redirects so far, oldest first.

func (*Faked) Restore

func (self *Faked) Restore()

Restore puts the real driver back. Register it with t.Cleanup so a fake on the default manager does not outlive its test.

type Flow

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

Flow is a driver bound to one request. It is a value: every method that shapes it returns a changed copy, so nothing leaks into the next request.

func Driver

func Driver(name string) Flow

Driver picks a configured driver on the default manager.

func (Flow) AuthURL

func (self Flow) AuthURL(w http.ResponseWriter, r *http.Request) (string, error)

AuthURL is Redirect without the redirecting, for a handler that sends the browser itself. It still starts the handshake for this browser.

func (Flow) Client

func (self Flow) Client(ctx context.Context, token *oauth2.Token) *http.Client

Client is an HTTP client that presents the token to the provider's API and refreshes it when it can.

func (Flow) Redirect

func (self Flow) Redirect(w http.ResponseWriter, r *http.Request) error

Redirect sends the browser to the provider.

func (Flow) RedirectURL

func (self Flow) RedirectURL(url string) Flow

RedirectURL overrides the callback for this request.

func (Flow) RefreshToken

func (self Flow) RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error)

RefreshToken trades a refresh token for a live token. Persist what comes back: it may carry a new refresh token, and the old one may be retired.

func (Flow) Scopes

func (self Flow) Scopes(scopes ...string) Flow

Scopes adds to the driver's scopes.

func (Flow) SetScopes

func (self Flow) SetScopes(scopes ...string) Flow

SetScopes replaces the driver's scopes.

func (Flow) Stateless

func (self Flow) Stateless() Flow

Stateless skips the handshake, for a callback this server did not start. Nothing is kept between the two steps, so PKCE is off and WithState has nowhere to go.

func (Flow) User

func (self Flow) User(w http.ResponseWriter, r *http.Request) (*User, error)

User finishes the flow at the callback and returns the person the provider vouches for, with their token.

func (Flow) UserFromToken

func (self Flow) UserFromToken(ctx context.Context, token *oauth2.Token) (*User, error)

UserFromToken asks the provider about a token the application already holds.

func (Flow) With

func (self Flow) With(params map[string]string) Flow

With adds parameters to the authorization URL: login_hint, prompt, access_type, whatever the provider understands.

func (Flow) WithState

func (self Flow) WithState(state map[string]string) Flow

WithState carries values through the flow. They come back on User.State at the callback, and never travel through the provider.

type Manager

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

Manager is a configured set of drivers. Configure sets up the default one; New makes another, for tests or a multi-tenant application.

func New

func New(config Config) (*Manager, error)

New makes a manager of its own.

func (*Manager) Driver

func (self *Manager) Driver(name string) Flow

Driver picks a configured driver. An unknown name is not an error here but on every call that follows, so a handler has one place to check.

func (*Manager) Extend

func (self *Manager) Extend(name string, factory Factory)

Extend registers a provider under a name. The factory receives the credentials configured under that name.

func (*Manager) Fake

func (self *Manager) Fake(name string, user User) *Faked

Fake replaces a driver with one that returns the given user at the callback. The redirect goes to a URL that does not exist, and the callback checks nothing: a test calls it directly and gets the user.

type OAuth2

type OAuth2 struct {
	Credentials Credentials
	Endpoint    oauth2.Endpoint

	// Scopes are the defaults, for credentials that name none.
	Scopes []string

	// ProfileURL is fetched with the token, and Map turns the document into a
	// User. Raw is filled in when Map leaves it empty, or when there is no
	// Map at all.
	ProfileURL string
	Map        func(raw map[string]any) User

	WithoutPKCE bool
}

OAuth2 is a provider described by its endpoints and a profile mapping, which is all most providers need.

func (OAuth2) Config

func (self OAuth2) Config() *oauth2.Config

func (OAuth2) User

func (self OAuth2) User(ctx context.Context, client *http.Client, _ *oauth2.Token) (*User, error)

func (OAuth2) UsesPKCE

func (self OAuth2) UsesPKCE() bool

type Provider

type Provider interface {
	Config() *oauth2.Config
	User(ctx context.Context, client *http.Client, token *oauth2.Token) (*User, error)
}

Provider is what a driver is made of: the oauth2 configuration it was issued, and how to read the person behind a token. The flow around those two things is this package's.

The client already presents the token, so a provider reads its profile with a plain Get.

func Bitbucket

func Bitbucket(credentials Credentials) Provider

Bitbucket reads the user document and the primary address from the emails endpoint, which is the only place Bitbucket puts it.

func Facebook

func Facebook(credentials Credentials) Provider

Facebook reads the Graph API's me document.

func GitHub

func GitHub(credentials Credentials) Provider

GitHub reads the profile and, when the profile hides the address, the primary verified one from the emails endpoint.

func GitLab

func GitLab(credentials Credentials) Provider

GitLab signs in against gitlab.com. For a self-hosted instance, Extend with an OAuth2 that points at it.

func Google

func Google(credentials Credentials) Provider

Google signs in through OpenID Connect. For a refresh token, redirect with With(map[string]string{"access_type": "offline", "prompt": "consent"}).

func LinkedIn

func LinkedIn(credentials Credentials) Provider

LinkedIn signs in through OpenID Connect.

func OpenID

func OpenID(credentials Credentials, issuer string) Provider

OpenID is a provider for any OpenID Connect issuer. Endpoints and signing keys come from discovery, and the identity from an ID token verified for its signature, issuer, audience, expiry and nonce.

sociable.Extend("okta", func(c sociable.Credentials) sociable.Provider {
	return sociable.OpenID(c, "https://acme.okta.com")
})

func Slack

func Slack(credentials Credentials) Provider

Slack signs in through OpenID Connect.

func Twitch

func Twitch(credentials Credentials) Provider

Twitch reads the Helix users document, which needs the client id on the request as well as the token.

func X

func X(credentials Credentials) Provider

X reads the users/me document. X issues no email.

type Redirect

type Redirect struct {
	Scopes      []string
	Params      map[string]string
	State       map[string]string
	RedirectURL string
}

Redirect is one call to Redirect or AuthURL on a faked driver, as the handler shaped it.

type Redirector

type Redirector interface {
	Redirect(w http.ResponseWriter, r *http.Request, callback, state string) error
}

Redirector is a provider that sends the browser itself, for a sign-in that is not an authorization URL: a widget, a form. It receives the callback URL and the state the callback must bring back.

type Session

type Session interface {
	Put(w http.ResponseWriter, r *http.Request, key, value string) error
	Pull(w http.ResponseWriter, r *http.Request, key string) (string, error)
}

Session keeps the handshake between the redirect and the callback. The default is an encrypted cookie; an application with a session of its own hands that over instead.

The value is the handshake as plain JSON, PKCE verifier included, so a Session must keep it server-side or encrypt it. It must also be bound to the browser that started the flow, which is what makes the state check mean anything.

type Unprotected

type Unprotected interface {
	UsesPKCE() bool
}

Unprotected is a provider that cannot do PKCE. Every provider that stays silent does it.

type User

type User struct {
	// ID is the provider's stable identifier for this person. Store this,
	// not the email, which can change.
	ID string

	Nickname      string
	Name          string
	Email         string
	EmailVerified bool
	Avatar        string

	// Raw is the provider's own document: the profile, or the ID token's
	// claims.
	Raw map[string]any

	// Token carries the access token, the refresh token when the provider
	// issued one, and the expiry.
	Token *oauth2.Token

	// ApprovedScopes are the scopes the provider granted, which need not be
	// the ones that were asked for.
	ApprovedScopes []string

	// State is what the call site put in with WithState.
	State map[string]string
}

User is who the provider vouches for.

func Claims

func Claims(claims map[string]any) User

Claims is the standard mapping of OpenID Connect claims to a User.

Jump to

Keyboard shortcuts

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