Social login
go tool gpsystem add auth --social discord,facebook,apple,google
add auth (see the CLI overview) always generates a provider-agnostic social login layer in the module: a markbates/goth-based provider registry, two endpoints (GET /social/{provider} and GET/POST /social/{provider}/callback), and a LoginWithProvider service method that issues tokens the same way the email+password login does. The --social flag only decides which of the four known providers (Discord, Facebook, Apple, Google) get pre-wired; any other goth provider (or a hand-written goth.Provider) is a one-line manual addition in the generated social/providers.go.
auth/ package is deliberately stateless (JWT sign/parse + middleware only), and the code --social turns on lands in the project's own go.mod (pulled in by go mod tidy). This follows from the same research that shaped the kit's shape: the kit is one module, and an OAuth library has no place in it when the generated auth module already lives in the project anyway (bcrypt, refresh rotation, email flows, all of it).Why this shape
The users/auth_credentials schema was designed for multiple providers from day one: the auth_credentials.provider + provider_key pair (UNIQUE (user_id, provider)) stores a local row (bcrypt hash) for email+password registration; a social login writes a discord/google/... row there with the provider's stable external user id. LoginWithProvider builds directly on this schema: there's no separate "social user" table, one user can log in through several providers.
Because the generated strict-server layer (TypeSpec → OpenAPI → oapi-codegen) gives handler methods the signature func(ctx context.Context, req ...) (Resp, error), there's no direct access to the underlying Fiber/chi request, so no convenient way to set a cookie between the begin and callback requests either. That's why the state parameter is a self-verifying, signed token (see below) rather than a server-side session: the whole flow stays stateless, in the same spirit as the kit's own auth/ package.
The login flow
sequenceDiagram
participant B as Browser
participant A as API (generated auth module)
participant P as Provider (e.g. Discord)
B->>A: GET /api/v1/auth/social/discord
A->>A: build signed state (HMAC, JWT_SECRET)
A-->>B: 302 Location: provider consent URL (state=...)
B->>P: consent screen
P-->>B: redirect /callback?code=...&state=...
B->>A: GET /api/v1/auth/social/discord/callback
A->>A: verify state, exchange code for access token
A->>P: GET /users/@me (Bearer access token)
P-->>A: profile (email, name, avatar, user id)
A->>A: find-or-create/link + IssueTokens
A-->>B: 200 TokenPair (accessToken, refreshToken, user)
- Begin.
GET /api/v1/{module}/social/{provider}builds the provider's consent URL with that provider's goth client, embedding a signedstateparameter, and302s the browser there (gen.SocialBegin302Response+ aLocationheader). - Consent. The user approves access on the provider's own page.
- Callback. The provider redirects back to
/social/{provider}/callback:- most providers (Discord, Google, Facebook) use the query string (
?code=...&state=...), aGETwithSocialCallbackQuery; - Apple uses form_post (
application/x-www-form-urlencodedbody), aPOSTwithSocialCallbackForm(see Apple specifics).
- most providers (Discord, Google, Facebook) use the query string (
- The handler verifies
state, exchangescodefor an access token, fetches the provider's profile, and normalizes the resultinggoth.Userinto asocial.ExternalUser(Provider,ProviderKey,Email,Name,AvatarURL). - The service's
LoginWithProviderruns the find-or-create/link logic (below), then calls the module's existingcore.Service.IssueTokens: the same JWT/refresh pair comes out as an email+password login would produce, and therbac/middleware layer is untouched.
Account-linking rule
- Provider identity already known (
provider+providerKeymatches an existingauth_credentialsrow): straight login,IssueTokensfor the existing user. - First social login for this identity, but the email belongs to an existing, verified user: the new provider credential gets linked to that user (who can now log in with email+password or with the social provider).
- The email belongs to an existing user whose email is NOT verified: rejected (
409 Conflict). An unverified email is not proof of ownership; auto-linking here would let an attacker who registered a social account with the same email string take over the victim's still-unverified local registration. - The email is not known at all: a brand new, pre-verified user is created (the provider already vouched for the address, and the kit accepts that).
- The provider returns no email (
SocialEmailRequirederror,422): the kit requires an email, sinceusers.emailis the account's key. Every built-in provider registration therefore requests an explicit email scope (below).
The four built-in providers
| Provider | goth package | Default scopes | Callback |
|---|---|---|---|
| Discord | providers/discord | identify, email | GET (query string) |
providers/facebook | email | GET (query string) | |
providers/google | email, profile | GET (query string) | |
| Apple | providers/apple | email | POST (form_post) |
Provider-side setup and env vars
Every non-Apple provider needs an OAuth2 app registered with it, with the redirect/callback URL set to {APP_BASE_URL}/api/v1/{module}/social/{provider}/callback (e.g. https://app.example.com/api/v1/auth/social/discord/callback).
| Provider | Env vars | Where to get them |
|---|---|---|
| Discord | OAUTH_DISCORD_CLIENT_ID, OAUTH_DISCORD_CLIENT_SECRET | Discord Developer Portal, OAuth2 tab |
OAUTH_FACEBOOK_CLIENT_ID, OAUTH_FACEBOOK_CLIENT_SECRET | Meta for Developers, Facebook Login product | |
OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET | Google Cloud Console, OAuth 2.0 Client ID | |
| Apple | OAUTH_APPLE_CLIENT_ID, OAUTH_APPLE_TEAM_ID, OAUTH_APPLE_KEY_ID, OAUTH_APPLE_PRIVATE_KEY | Apple Developer, Sign in with Apple + Keys |
These are only required (env:"...,required") when the matching --social flag was passed to add auth: the platform config only gets fields for the providers actually turned on.
OAUTH_APPLE_PRIVATE_KEY is a PKCS8 PEM private key (the contents of the downloaded .p8 file for your Sign in with Apple key). The generated providers.go mints a JWT from it, the team id and the key id on every startup (apple.MakeSecret): Apple has no static client secret, only a signed token valid for at most 6 months. A malformed key makes RegisterProviders return an error, which the generated Register{{Surface}} turns into a startup panic (deliberately: this is a configuration mistake, not a runtime condition).Apple specifics
- form_post callback. Requesting the
email/namescope makes goth's Apple provider switch onresponse_mode=form_post: Apple POSTs thecode/statepair as anapplication/x-www-form-urlencodedbody to the callback URL instead of a query string. That's why there's a separatePOST /social/{provider}/callbackoperation (SocialCallbackForm) alongside the usualGET; both call the samesocial.Registry.Complete. - First consent only. Apple sends the name only on the first approval (in a
userfield, as JSON); a repeat login only carriessub(the stable user id) and, if enabled, the email. The generated code falls back to the email as the name when the profile doesn't provide one. - Private relay email. If the user picks "Hide My Email",
Emailis an Apple-generated@privaterelay.appleid.comaddress, stable for that user but not their real one.
Adding a provider by hand
The generic layer (internal/modules/{module}/social/social.go) never changes; every extension happens in providers.go, below the // gpsystem:social-providers marker.
Another goth provider (one of the ~60 built in, e.g. GitLab):
// providers.go
import "github.com/markbates/goth/providers/gitlab"
func RegisterProviders(reg *Registry, cfg Config) error {
// gpsystem:social-providers
if cfg.GitLab.ClientID != "" {
reg.Register("gitlab", gitlab.New(cfg.GitLab.ClientID, cfg.GitLab.ClientSecret, callbackURL(cfg, "gitlab")))
}
// ...
}
That needs a GitLab ProviderConfig field on Config (config.go), an OAUTH_GITLAB_CLIENT_ID/_SECRET pair on the platform config (config.go, the // gpsystem:config anchor), and the matching fields on Dependencies (register.go) and the social.Config{...} literal in Register{{Surface}}: exactly the same spots --social generates for the built-in four.
A provider with no goth package: implement goth.Provider (BeginAuth, UnmarshalSession, FetchUser, Name/SetName, Debug, RefreshToken/RefreshTokenAvailable) on your own type, then reg.Register("name", &MyProvider{...}) the same way.
Troubleshooting
404 unknown social provider: the{provider}path segment isn't registered (neither via--socialnor by hand), or the name is misspelled.401 social login failed:stateis invalid (expired, older than 10 minutes, or meant for a different provider), or the code exchange failed at the provider (most often: a redirect URI mismatch between the provider's app settings and the one computed fromAPP_BASE_URL).422at the callback,SocialEmailRequired: the provider returned no email; check that the email scope is included (it is by default for all four built-ins).409 Conflict: the email belongs to an existing but unverified user; that user needs to verify their own email+password registration first (see Authentication).
Related pages
- Authentication: the kit's
auth/package, JWT issuing, middleware. - RBAC: role/permission checks over the
Identitya token turns into, unchanged for social logins. - Configuration: the kit's own
envPrefix-composable configs (theOAUTH_*vars are the generated project's own config, not covered there).