Social login with goth, standalone
The social login page covers --social-generated code inside a gpsystem project. This page is the standalone version of the same idea: a plain net/http app, no gpsystem CLI, no oapi-codegen strict-server layer, that uses markbates/goth to talk to Discord, Facebook and Google, and gp-system/auth to turn the result into the same JWT/refresh pair an email+password login would produce.
goth is your project's dependency here, not auth's: auth's own go.mod requires only golang-jwt/jwt/v5 (see Using auth standalone). Nothing in this example changes that; goth and gorilla/sessions land in your go.mod, not auth's.go mod init example.com/social-demo
go get github.com/gp-system/auth
go get github.com/markbates/goth
Why this is simpler than the generated version
The kit's generated social package signs its own state parameter instead of using a server-side session, because the oapi-codegen strict-server handler signature (func(ctx, req) (resp, error)) never exposes the raw http.ResponseWriter, so there is nowhere to set a cookie between the begin and callback requests. A hand-rolled net/http app has no such restriction: it can use goth's own gothic helper package as-is, cookie session and all, which is the shape goth's own docs assume.
Registering providers
package main
import (
"os"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/discord"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/google"
)
const baseURL = "http://localhost:8080"
func registerProviders() {
goth.UseProviders(
discord.New(os.Getenv("DISCORD_CLIENT_ID"), os.Getenv("DISCORD_CLIENT_SECRET"),
baseURL+"/auth/discord/callback", discord.ScopeIdentify, discord.ScopeEmail),
facebook.New(os.Getenv("FACEBOOK_CLIENT_ID"), os.Getenv("FACEBOOK_CLIENT_SECRET"),
baseURL+"/auth/facebook/callback", "email"),
google.New(os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET"),
baseURL+"/auth/google/callback", "email", "profile"),
)
}
Each provider's app console (Discord Developer Portal, Meta for Developers, Google Cloud Console) needs the matching callback URL registered, exactly as in the generated version's provider-side setup table.
Routing with gothic
gothic.BeginAuthHandler and gothic.CompleteUserAuth read the provider name straight off the request; with Go's net/http.ServeMux route patterns (Go 1.22+), req.PathValue("provider") is one of the places gothic.GetProviderName already checks, so a plain stdlib mux is enough, no router library required:
package main
import (
"net/http"
"os"
"github.com/gorilla/sessions"
"github.com/markbates/goth/gothic"
)
func main() {
gothic.Store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_SECRET")))
registerProviders()
mux := http.NewServeMux()
mux.HandleFunc("GET /auth/{provider}", gothic.BeginAuthHandler)
mux.HandleFunc("GET /auth/{provider}/callback", handleCallback)
http.ListenAndServe(":8080", mux)
}
SESSION_SECRET signs the cookie gothic stores the OAuth session in between the begin and callback requests; treat it the same as JWT_SECRET, a per-environment value, never checked in.
The callback: goth.User to a gp-system/auth token
This is the part that touches auth. gothic.CompleteUserAuth hands back a goth.User (its Provider, UserID, Email, Name, AvatarURL fields); findOrCreateUser below is where you'd apply the same lookup rule the generated version documents (match on provider + provider key first, fall back to email, reject linking onto an unverified local account), just written by hand instead of generated:
package main
import (
"encoding/json"
"net/http"
"time"
"github.com/gp-system/auth"
"github.com/markbates/goth"
"github.com/markbates/goth/gothic"
)
var issuer = auth.NewTokenIssuer[auth.DefaultClaims](auth.Config{
Secret: "dev-secret-change-me", // in real code: from env/secret manager
AccessTTL: 15 * time.Minute,
Issuer: "social-demo",
})
func handleCallback(w http.ResponseWriter, r *http.Request) {
gothUser, err := gothic.CompleteUserAuth(w, r)
if err != nil {
http.Error(w, "social login failed", http.StatusUnauthorized)
return
}
user, err := findOrCreateUser(gothUser)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
claims := auth.NewDefaultClaims(auth.Config{Issuer: "social-demo", AccessTTL: 15 * time.Minute},
user.ID, user.Username, user.Roles, user.Permissions)
token, err := issuer.Sign(claims)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
refresh, err := auth.NewRefreshToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// store a hash of refresh against user.ID here, same as an email+password login would
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"accessToken": token, "refreshToken": refresh})
}
// findOrCreateUser is your own persistence, not part of auth or goth. Look
// up by (provider, gothUser.UserID) first (returning user); then by email
// (link to an existing, verified account, or reject if that email belongs
// to an unverified one); otherwise create a new, pre-verified user, since
// the provider already vouched for the email address.
func findOrCreateUser(gothUser goth.User) (localUser, error) {
panic("left as an exercise: your own users table")
}
type localUser struct {
ID string
Username string
Roles []string
Permissions []string
}
From here on, every other page in this section applies unchanged: the token handleCallback returns is verified with the same auth.Identify/rbac.CheckRole calls as a token minted from an email+password login, because it is one, just signed after a goth.User lookup instead of a bcrypt comparison.
What the kit adds on top
The generated social package (go tool gpsystem add auth --social discord,facebook,google) is this same idea with three things bolted on that a real project usually wants: a signed, stateless state parameter instead of a cookie session (so it works from the strict-server handler shape), the account-linking rule implemented once against the kit's users/auth_credentials schema instead of a panic("left as an exercise"), and Apple's form_post callback and JWT client-secret handling. See Social login for that version.