With the scaffold#
vukem install:auth --social github,googleThis adds /auth/{provider}/redirect and /auth/{provider}/callback, the social_accounts model and migration (unique provider + provider id), provider setup from SOCIAL_<NAME>_CLIENT_ID and SOCIAL_<NAME>_CLIENT_SECRET (also appended to .env.example), and account linking:
- an existing link signs in;
- an email the provider verified links to the existing account; when that local account was never verified, its password is replaced with a random one and its sessions, remember-me and API tokens end (like a password reset), since they may belong to whoever signed up first with the address;
- an unverified email matching an account is refused with 409, so a provider account cannot take over a password account;
- otherwise a new user is created.
It also works on projects that already ran install:auth.
The social package#
key, err := app.DeriveKey("social.state", 32) // config.ErrKeyMissing without APP_KEY
if err != nil {
return err
}
s := social.New(key, // panics on a key shorter than 32 bytes
social.GitHub(social.Config{ClientID: id, ClientSecret: secret, RedirectURL: "https://app.test/auth/github/callback"}),
social.Google(googleCfg), // OpenID Connect
social.Microsoft("common", microsoftCfg), // OpenID Connect (Entra ID)
social.GitLab(gitlabCfg), social.Discord(discordCfg),
social.OIDC("okta", "https://example.okta.com", oktaCfg), // any OIDC issuer
).WithPreviousKeys(app.DerivePreviousKeys("social.state", 32)...) // APP_KEY rotation
r.Get("/auth/{provider}/redirect", func(c *web.Ctx) error {
return s.Redirect(c, c.Param("provider")) // 404 for unknown providers
})
r.Get("/auth/{provider}/callback", func(c *web.Ctx) error {
u, err := s.User(c, c.Param("provider"))
if err != nil {
return err // 400 bad or expired state, 403 cancelled, 502 provider failure
}
// u.Provider, u.ID, u.Email, u.EmailVerified, u.Name, u.Nickname, u.AvatarURL, u.Token
return nil
})| Feature | Detail |
|---|---|
| Flow | Authorization Code + PKCE (S256) for every provider; htmx requests get HX-Redirect |
| State | state, PKCE verifier and nonce in a 10-minute, HMAC-signed, HttpOnly, SameSite=Lax cookie; compared in constant time; used states are remembered, so a replayed callback fails. Set s.States to the cache store (c, _ := app.Cache(); s.States = c.Store(), done by install:auth --social) so this holds across replicas and restarts. In production use a persistent, shared store (CACHE_STORE=database or Redis): the memory store evicts entries past CACHE_MAX_ENTRIES, and an evicted state could be replayed until its cookie expires; a store error refuses the callback (503) |
| OIDC | discovery, JWKS cache (refreshed once for unknown key ids), RS256/ES256 only, checks of iss, aud/azp, exp/iat (1 minute leeway) and nonce |
| Email trust | EmailVerified only when the provider vouches: GitHub primary verified address, GitLab confirmed_at, Discord verified, OIDC email_verified (Microsoft xms_edov) |
| Stateless | s.UserFromToken(ctx, "google", accessToken) for API/SPA flows |
| Options | social.WithEndpoints (GitHub Enterprise, self-hosted GitLab), social.WithAuthParams, Config.Scopes, social.OAuth2(...) for any OAuth2 server |
Testing#
user := social.User{ID: "gh-1", Email: "ana@example.com", EmailVerified: true, Name: "Ana"}
app := vukemtest.New(t, bootstrap.New, vukemtest.Options{Env: vukemtest.FakeSocial(user)})social.Fake(name, callbackURL, user) is a provider without network calls; vukemtest.FakeSocial enables it in scaffolded test apps only.
Apple, LinkedIn and X#
social.LinkedIn(cfg) (OIDC), social.X(cfg) (OAuth 2.0 + PKCE; X returns no email) and social.Apple(social.AppleConfig{Config, TeamID, KeyID, PrivateKey}). vukem install:auth --social apple,linkedin,x wires all three.
Sign in with Apple signs its client secret (an ES256 JWT, cached for an hour) with the .p8 key and answers with response_mode=form_post: the callback is a cross-site POST from appleid.apple.com. Register POST on the callback route, serve the app over HTTPS, and add https://appleid.apple.com to CSRF_TRUSTED_ORIGINS. Apple sends the user's name only on the first sign-in. It reads SOCIAL_APPLE_CLIENT_ID, SOCIAL_APPLE_TEAM_ID, SOCIAL_APPLE_KEY_ID and SOCIAL_APPLE_PRIVATE_KEY_PATH.