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

sociable

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 26 Imported by: 0

README

sociable

Social sign-in on net/http, shaped after Laravel Socialite. You get back who the person is and a token to use.

social := sociable.New(
    sociable.WithKey(os.Getenv("APP_KEY")),
    sociable.WithAPIURL("https://api.example"),
    sociable.WithDriver[sociable.GoogleProvider]("google", sociable.Credentials{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        RedirectURL:  "/auth/google/callback",
    }),
)
http.HandleFunc("/auth/google", func(w http.ResponseWriter, r *http.Request) {
    social.Driver("google").Redirect(w, r)
})

http.HandleFunc("/auth/google/callback", func(w http.ResponseWriter, r *http.Request) {
    user, err := social.Driver("google").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
})

Configuration

Option
WithKey encrypts the cookie that carries the handshake between redirect and callback. Any string.
WithSession an application's own session, instead of the cookie. WithKey is then not needed.
WithAPIURL the application's own URL. A RedirectURL given as a path is resolved against it, and https:// marks the cookie Secure behind a proxy.
WithClient the HTTP client every call to a provider goes through: the exchange, the refresh, the profile and discovery. A traced transport there traces all of it.
WithDriver[Provider] registers a provider type under a name, with its ClientID, ClientSecret, RedirectURL and, for a self-hosted provider, BaseURL.

APP_KEY and API_URL are read from the environment when the option is not given.

Shaping the redirect

All optional, all chainable, the same names as Socialite:

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

social.Driver("google").SetScopes("openid", "email").Redirect(w, r)      // replaces them
social.Driver("google").RedirectURL("https://other.example/cb").Redirect(w, r)
social.Driver("google").Stateless().Redirect(w, r)                       // no session, no state check

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

Tokens

user, err := social.Driver("google").UserFromToken(ctx, token)        // a token you already hold
token, err := social.Driver("google").RefreshToken(ctx, refreshToken) // a live one; persist what comes back

Errors

user, err := social.Driver("google").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 tampered with
case errors.Is(err, sociable.ErrUnknownDriver): // no driver configured under that name
case errors.Is(err, sociable.ErrDriverNotConfigured): // no credentials, endpoints or redirect URL
case errors.Is(err, sociable.ErrIDToken):       // the OpenID Connect id token did not verify
case err != nil:                                // the provider refused, the exchange or the profile failed
}

Every provider refusal matches ErrAuthorization and carries the provider's words as an *AuthorizationError.

Your own provider

A provider is its endpoints, its scopes, and how it reads the person behind a token. The client it gets already presents the token.

type Acme struct{}

func (Acme) Endpoint(c sociable.Credentials) oauth2.Endpoint {
    return oauth2.Endpoint{AuthURL: c.BaseURL + "/oauth/authorize", TokenURL: c.BaseURL + "/oauth/token"}
}

func (Acme) Scoping() sociable.Scoping {
    return sociable.Scoping{Scopes: []string{"profile", "email"}, Separator: " "}
}

func (Acme) GetUserByToken(ctx context.Context, client *http.Client, token *oauth2.Token, c sociable.Credentials) (*fastjson.Value, error) {
    response, err := client.Get(c.BaseURL + "/oauth/userinfo")
    // ... read the body, fastjson.ParseBytes
}

func (Acme) MapUserToStruct(json *fastjson.Value) (*sociable.User, error) {
    return &sociable.User{ID: string(json.GetStringBytes("sub")), Email: string(json.GetStringBytes("email"))}, nil
}

The provider is a zero value: what it needs to know, an issuer or a host, comes in through Credentials.BaseURL.

Optional, when it applies:

func (Acme) Issuer() string { return "https://acme.example" } // OpenID Connect: the id token is verified
func (Acme) UsesPKCE() bool { return false }                  // a provider that cannot do PKCE

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

sociable.WithDriver[Acme]("acme", sociable.Credentials{BaseURL: "https://acme.example", ...})
social.Driver("acme").Redirect(w, r)

Tests

Fake swaps a driver for one that returns the user you give it, the way Socialite::fake does. A test makes a manager of its own and hands it to the application, so nothing needs configuring, nothing talks to a provider, and tests run in parallel:

social := sociable.New()
social.Fake("github", &sociable.User{ID: "github-123", Email: "jason@example.com"})

recorder := httptest.NewRecorder()
App(social).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"))
}

Fake("github") without a user makes the redirect go to a URL nobody serves, for testing the redirect side.

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, and a handshake that expires after ten minutes 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.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrUnknownDriver = errors.New("sociable: unknown driver")

	// ErrDriverNotConfigured means the driver has no client id, no endpoints
	// or no redirect URL. Refuse the route rather than redirect.
	ErrDriverNotConfigured = errors.New("sociable: driver not configured")

	ErrMissingKeyOrSession = errors.New("sociable: a Key or a Session is required")
	ErrSessionSealed       = errors.New("sociable: the cookie cannot be opened")
)
View Source
var (
	// ErrInvalidState means the callback was not started here, was already
	// used, 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.
	ErrExchange = errors.New("sociable: token exchange failed")

	// ErrProfile means the provider would not describe the person.
	ErrProfile = errors.New("sociable: could not read the profile")

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

Functions

func WithAPIURL added in v0.4.0

func WithAPIURL(url string) option

func WithClient added in v0.5.0

func WithClient(client *http.Client) option

WithClient sets the HTTP client every call to a provider goes through, so a traced or timed-out transport there covers all of them.

func WithDriver added in v0.4.0

func WithDriver[T Provider](name string, credentials Credentials) option

WithDriver registers a provider type under a name.

func WithKey added in v0.4.0

func WithKey(key string) option

func WithSession added in v0.4.0

func WithSession(session Session) option

Types

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 BitbucketProvider added in v0.4.0

type BitbucketProvider struct{}

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

func (BitbucketProvider) Endpoint added in v0.4.0

func (BitbucketProvider) GetUserByToken added in v0.4.0

func (self BitbucketProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (BitbucketProvider) MapUserToStruct added in v0.4.0

func (self BitbucketProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (BitbucketProvider) Scoping added in v0.4.0

func (self BitbucketProvider) Scoping() Scoping

type Credentials

type Credentials struct {
	ClientID     string
	ClientSecret string

	// BaseURL is the provider's, for one that is self-hosted or an OpenID
	// Connect issuer. A built-in provider has its own default.
	BaseURL     string
	RedirectURL string
}

Credentials are what a provider issued the application.

type DriverContract added in v0.4.0

type DriverContract interface {
	Scopes(scopes ...string) DriverContract
	SetScopes(scopes ...string) DriverContract
	With(params map[string]string) DriverContract
	RedirectURL(url string) DriverContract
	Stateless() DriverContract

	Redirect(w http.ResponseWriter, r *http.Request) error
	AuthURL(w http.ResponseWriter, r *http.Request) (string, error)
	User(w http.ResponseWriter, r *http.Request) (*User, error)
	Callback(w http.ResponseWriter, r *http.Request) (*oauth2.Token, error)
	UserFromToken(ctx context.Context, token *oauth2.Token) (*User, error)
	RefreshToken(ctx context.Context, refreshToken string) (*oauth2.Token, error)
}

DriverContract is what Driver hands back: Socialite's provider surface, the chain that shapes a redirect and the calls that run the flow. A fake implements it too.

type FacebookProvider added in v0.4.0

type FacebookProvider struct{}

FacebookProvider reads the Graph API's me document, with the appsecret_proof Facebook asks for from server-side apps.

func (FacebookProvider) Endpoint added in v0.4.0

func (FacebookProvider) GetUserByToken added in v0.4.0

func (self FacebookProvider) GetUserByToken(ctx context.Context, client *http.Client, token *oauth2.Token, credentials Credentials) (*fastjson.Value, error)

func (FacebookProvider) MapUserToStruct added in v0.4.0

func (self FacebookProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (FacebookProvider) Scoping added in v0.4.0

func (self FacebookProvider) Scoping() Scoping

type GitHubProvider added in v0.4.0

type GitHubProvider struct{}

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

func (GitHubProvider) Endpoint added in v0.4.0

func (self GitHubProvider) Endpoint(Credentials) oauth2.Endpoint

func (GitHubProvider) GetUserByToken added in v0.4.0

func (self GitHubProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (GitHubProvider) MapUserToStruct added in v0.4.0

func (self GitHubProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (GitHubProvider) Scoping added in v0.4.0

func (self GitHubProvider) Scoping() Scoping

type GitLabProvider added in v0.4.0

type GitLabProvider struct{}

GitLabProvider signs in against gitlab.com, or a self-hosted instance given as BaseURL.

func (GitLabProvider) Endpoint added in v0.4.0

func (self GitLabProvider) Endpoint(credentials Credentials) oauth2.Endpoint

func (GitLabProvider) GetUserByToken added in v0.4.0

func (self GitLabProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, credentials Credentials) (*fastjson.Value, error)

func (GitLabProvider) MapUserToStruct added in v0.4.0

func (self GitLabProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (GitLabProvider) Scoping added in v0.4.0

func (self GitLabProvider) Scoping() Scoping

type GoogleProvider added in v0.4.0

type GoogleProvider struct{}

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

func (GoogleProvider) Endpoint added in v0.4.0

func (self GoogleProvider) Endpoint(Credentials) oauth2.Endpoint

func (GoogleProvider) GetUserByToken added in v0.4.0

func (self GoogleProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (GoogleProvider) Issuer added in v0.4.0

func (self GoogleProvider) Issuer() string

func (GoogleProvider) MapUserToStruct added in v0.4.0

func (self GoogleProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (GoogleProvider) Scoping added in v0.4.0

func (self GoogleProvider) Scoping() Scoping

type LinkedInProvider added in v0.4.0

type LinkedInProvider struct{}

LinkedInProvider signs in through OpenID Connect.

func (LinkedInProvider) Endpoint added in v0.4.0

func (LinkedInProvider) GetUserByToken added in v0.4.0

func (self LinkedInProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (LinkedInProvider) Issuer added in v0.4.0

func (self LinkedInProvider) Issuer() string

func (LinkedInProvider) MapUserToStruct added in v0.4.0

func (self LinkedInProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (LinkedInProvider) Scoping added in v0.4.0

func (self LinkedInProvider) Scoping() Scoping

type Manager

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

Manager is a configured set of drivers, Socialite's SocialiteManager. An application makes one with New at boot and hands it to its handlers; a test makes its own and fakes on it, so tests share nothing and run in parallel.

func New

func New(options ...option) *Manager

New makes a manager of its own.

func (*Manager) Configure added in v0.4.0

func (self *Manager) Configure(options ...option)

Configure sets the manager up from scratch: the environment first, then the options over it.

func (*Manager) Driver

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

Driver picks a configured driver by name.

func (*Manager) Fake

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

Fake swaps the driver for one that redirects to a URL nobody serves and answers the callback with user, the way Socialite::fake does. Without a user, User falls through to the real driver.

type Provider

type Provider interface {
	Endpoint(credentials Credentials) oauth2.Endpoint
	Scoping() Scoping

	// GetUserByToken fetches the provider's document for the person. The
	// client already presents the token, the way Socialite's getHttpClient
	// does. Socialite's getUserByToken.
	GetUserByToken(ctx context.Context, client *http.Client, token *oauth2.Token, credentials Credentials) (*fastjson.Value, error)

	// MapUserToStruct turns that document into a User. The driver calls it,
	// and fills in the token afterwards. Socialite's mapUserToObject.
	MapUserToStruct(json *fastjson.Value) (*User, error)
}

Provider is what a driver is made of, Socialite's four abstract methods: where to send the browser, what to ask for, how to read the raw document behind a token, and how to map it. The provider is a zero value, so what it needs to know comes in through the credentials.

type Scoping added in v0.4.0

type Scoping struct {
	Scopes    []string
	Separator string
}

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 way Socialite keeps it in Laravel's session. The default is an encrypted cookie; an application with a session of its own hands that over with WithSession.

The value holds the PKCE verifier, so a Session must keep it server-side or encrypted, and bound to the browser that started the flow.

type SlackProvider added in v0.4.0

type SlackProvider struct{}

SlackProvider signs in through OpenID Connect. These are Slack's Sign in with Slack endpoints, not the workspace-app ones in x/oauth2.

func (SlackProvider) Endpoint added in v0.4.0

func (self SlackProvider) Endpoint(Credentials) oauth2.Endpoint

func (SlackProvider) GetUserByToken added in v0.4.0

func (self SlackProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (SlackProvider) Issuer added in v0.4.0

func (self SlackProvider) Issuer() string

func (SlackProvider) MapUserToStruct added in v0.4.0

func (self SlackProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (SlackProvider) Scoping added in v0.4.0

func (self SlackProvider) Scoping() Scoping

type TwitchProvider added in v0.4.0

type TwitchProvider struct{}

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

func (TwitchProvider) Endpoint added in v0.4.0

func (self TwitchProvider) Endpoint(Credentials) oauth2.Endpoint

func (TwitchProvider) GetUserByToken added in v0.4.0

func (self TwitchProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, credentials Credentials) (*fastjson.Value, error)

func (TwitchProvider) MapUserToStruct added in v0.4.0

func (self TwitchProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (TwitchProvider) Scoping added in v0.4.0

func (self TwitchProvider) Scoping() Scoping

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

	// 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
}

User is who the provider vouches for.

type XProvider added in v0.4.0

type XProvider struct{}

XProvider reads the users/me document. X issues an email only to apps approved for it.

func (XProvider) Endpoint added in v0.4.0

func (self XProvider) Endpoint(Credentials) oauth2.Endpoint

func (XProvider) GetUserByToken added in v0.4.0

func (self XProvider) GetUserByToken(ctx context.Context, client *http.Client, _ *oauth2.Token, _ Credentials) (*fastjson.Value, error)

func (XProvider) MapUserToStruct added in v0.4.0

func (self XProvider) MapUserToStruct(json *fastjson.Value) (*User, error)

func (XProvider) Scoping added in v0.4.0

func (self XProvider) Scoping() Scoping

Directories

Path Synopsis
examples
basic command
Sign in with Google on net/http.
Sign in with Google on net/http.
custom-provider command
A provider of your own: any OpenID Connect issuer, given as BaseURL.
A provider of your own: any OpenID Connect issuer, given as BaseURL.
testing command
A sign-in handler and its test, with Fake standing in for GitHub.
A sign-in handler and its test, with Fake standing in for GitHub.

Jump to

Keyboard shortcuts

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