Security

Social login

Wiring Discord, Facebook, Apple, Google, or any other OAuth2 provider into the add auth module.
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.

goth is a dependency of the generated project, not the kit's: the kit's 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)
  1. Begin. GET /api/v1/{module}/social/{provider} builds the provider's consent URL with that provider's goth client, embedding a signed state parameter, and 302s the browser there (gen.SocialBegin302Response + a Location header).
  2. Consent. The user approves access on the provider's own page.
  3. Callback. The provider redirects back to /social/{provider}/callback:
    • most providers (Discord, Google, Facebook) use the query string (?code=...&state=...), a GET with SocialCallbackQuery;
    • Apple uses form_post (application/x-www-form-urlencoded body), a POST with SocialCallbackForm (see Apple specifics).
  4. The handler verifies state, exchanges code for an access token, fetches the provider's profile, and normalizes the resulting goth.User into a social.ExternalUser (Provider, ProviderKey, Email, Name, AvatarURL).
  5. The service's LoginWithProvider runs the find-or-create/link logic (below), then calls the module's existing core.Service.IssueTokens: the same JWT/refresh pair comes out as an email+password login would produce, and the rbac/middleware layer is untouched.

Account-linking rule

  • Provider identity already known (provider + providerKey matches an existing auth_credentials row): straight login, IssueTokens for 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 (SocialEmailRequired error, 422): the kit requires an email, since users.email is the account's key. Every built-in provider registration therefore requests an explicit email scope (below).

The four built-in providers

Providergoth packageDefault scopesCallback
Discordproviders/discordidentify, emailGET (query string)
Facebookproviders/facebookemailGET (query string)
Googleproviders/googleemail, profileGET (query string)
Appleproviders/appleemailPOST (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).

ProviderEnv varsWhere to get them
DiscordOAUTH_DISCORD_CLIENT_ID, OAUTH_DISCORD_CLIENT_SECRETDiscord Developer Portal, OAuth2 tab
FacebookOAUTH_FACEBOOK_CLIENT_ID, OAUTH_FACEBOOK_CLIENT_SECRETMeta for Developers, Facebook Login product
GoogleOAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRETGoogle Cloud Console, OAuth 2.0 Client ID
AppleOAUTH_APPLE_CLIENT_ID, OAUTH_APPLE_TEAM_ID, OAUTH_APPLE_KEY_ID, OAUTH_APPLE_PRIVATE_KEYApple 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/name scope makes goth's Apple provider switch on response_mode=form_post: Apple POSTs the code/state pair as an application/x-www-form-urlencoded body to the callback URL instead of a query string. That's why there's a separate POST /social/{provider}/callback operation (SocialCallbackForm) alongside the usual GET; both call the same social.Registry.Complete.
  • First consent only. Apple sends the name only on the first approval (in a user field, as JSON); a repeat login only carries sub (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", Email is an Apple-generated @privaterelay.appleid.com address, 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 --social nor by hand), or the name is misspelled.
  • 401 social login failed: state is 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 from APP_BASE_URL).
  • 422 at 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).
  • Authentication: the kit's auth/ package, JWT issuing, middleware.
  • RBAC: role/permission checks over the Identity a token turns into, unchanged for social logins.
  • Configuration: the kit's own envPrefix-composable configs (the OAUTH_* vars are the generated project's own config, not covered there).
Copyright © 2026