feat: fb and google oauth

This commit is contained in:
Krispire Admin
2026-06-02 12:14:11 +05:30
parent 72f4e6527c
commit 0a348593b1
14 changed files with 872 additions and 36 deletions
+19
View File
@@ -65,6 +65,18 @@ type Config struct {
// Frontend
FrontendURL string // for CORS + reset/verify links in emails
// BFF public URL — the externally-reachable origin of this server. Used
// to build the OAuth redirect_uri the provider sees (must match the
// allow-list configured in the provider's console). In dev this is
// usually http://localhost:8080.
BFFPublicURL string
// OAuth providers — empty creds disable the corresponding provider.
GoogleOAuthClientID string
GoogleOAuthClientSecret string
FacebookOAuthClientID string
FacebookOAuthClientSecret string
}
// Load reads configuration from environment variables (with .env fallback).
@@ -116,6 +128,13 @@ func Load() (*Config, error) {
CookieSecure: getEnvBool("COOKIE_SECURE", false),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
}
if cfg.MongoURI == "" {
+219
View File
@@ -0,0 +1,219 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"go.uber.org/zap"
)
// stateCookieName is the short-lived cookie that pairs with the state query
// param so callback can verify "this redirect was started by us." Scoped to
// /api/v1/auth/oauth so it never leaks into other handlers.
const stateCookieName = "spsays_oauth_state"
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
type OAuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
providers oauth.Registry
stateSecret []byte // HMAC key for signing state — derived from JWT secret
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
logger *zap.Logger
}
func NewOAuthHandler(
svc *service.AuthService,
cookies auth.CookieOptions,
providers oauth.Registry,
stateSecret []byte,
bffPublicURL, frontendURL string,
logger *zap.Logger,
) *OAuthHandler {
return &OAuthHandler{
svc: svc,
cookies: cookies,
providers: providers,
stateSecret: stateSecret,
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
frontendURL: strings.TrimRight(frontendURL, "/"),
logger: logger,
}
}
// Start kicks off the OAuth round-trip. It generates a signed state value,
// stores it in a short-lived cookie, and 302s the browser to the provider's
// AuthCodeURL. `next` query param carries the post-login redirect path on
// the frontend (defaults to "/u/me").
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
http.Error(w, "unknown provider", http.StatusNotFound)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next)
if err != nil {
h.logger.Error("oauth encode state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// State cookie matches the auth cookie's SameSite/Secure profile so the
// browser sends it back on the callback redirect. Path-scoped so it
// doesn't ride along on unrelated requests.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
cfg := prov.Config(h.callbackURL(providerName))
authURL := cfg.AuthCodeURL(state)
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback finishes the round-trip. Verifies state, exchanges the code,
// fetches the profile, signs the user in, sets the session cookie, and
// redirects back to the frontend `next` path.
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
return
}
// Provider returned an error (user denied, etc.).
if e := r.URL.Query().Get("error"); e != "" {
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
return
}
// State must be present in BOTH cookie and query param, and they must
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
// query alone → an attacker could craft any signed state).
cookie, err := r.Cookie(stateCookieName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=missing_state")
return
}
stateQ := r.URL.Query().Get("state")
if stateQ == "" || stateQ != cookie.Value {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
// Clear the state cookie regardless of what happens next.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
if err != nil || stateProvider != providerName {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
code := r.URL.Query().Get("code")
if code == "" {
h.redirectErr(w, r, "/login?oauth_error=missing_code")
return
}
cfg := prov.Config(h.callbackURL(providerName))
tok, err := cfg.Exchange(r.Context(), code)
if err != nil {
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
return
}
prof, err := prov.FetchProfile(r.Context(), tok)
if err != nil {
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
return
}
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
if err != nil {
switch {
case errors.Is(err, service.ErrOAuthEmailMissing):
h.redirectErr(w, r, "/login?oauth_error=email_required")
case errors.Is(err, service.ErrOAuthUnavailable):
h.redirectErr(w, r, "/login?oauth_error=unavailable")
default:
h.logger.Error("oauth sign-in", zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=server")
}
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
}
// callbackURL composes the redirect_uri the provider's console must allow-list.
func (h *OAuthHandler) callbackURL(provider string) string {
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
}
// redirectErr sends the user back to the frontend with a single error query
// param so the login page can render something meaningful. No internal details
// leak through this path.
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
}
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
// containing a scheme, host, or whitespace is dropped to the default. This is
// the same anti-open-redirect treatment we apply on the share-shortener.
func sanitizeNext(raw string) string {
const def = "/u/me"
if raw == "" {
return def
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return def
}
if strings.ContainsAny(raw, " \t\r\n") {
return def
}
if len(raw) > 512 {
return def
}
return raw
}
+19 -2
View File
@@ -5,7 +5,9 @@ import "time"
// User represents a row in the `users` table.
//
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
// even if a caller accidentally passes a User through response.JSON.
// even if a caller accidentally passes a User through response.JSON. It's a
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
@@ -19,7 +21,7 @@ type User struct {
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
EmailVerifiedAt *time.Time `json:"email_verified_at"`
PasswordHash string `json:"-"`
PasswordHash *string `json:"-"`
TokenVersion int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -42,6 +44,21 @@ const (
PurposeEmailVerification = "email_verification"
)
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
// per (provider, provider_user_id). A single user can have several linked
// providers.
type OAuthAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Provider string `json:"provider"`
ProviderUserID string `json:"provider_user_id"`
Email *string `json:"email"`
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ShortURL represents a shortened URL.
type ShortURL struct {
ID string `json:"id"`
+69
View File
@@ -0,0 +1,69 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
// OAuthAccountRepo manages provider linkage rows.
type OAuthAccountRepo struct {
pg *Postgres
}
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
return &OAuthAccountRepo{pg: pg}
}
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
// this provider identity has been seen before and we just log the user in.
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
const q = `
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
FROM oauth_accounts
WHERE provider = $1 AND provider_user_id = $2
LIMIT 1`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOAuthAccountNotFound
}
return nil, fmt.Errorf("find oauth account: %w", err)
}
return a, nil
}
// Link inserts a new oauth_accounts row tying a provider identity to an
// existing user. Idempotent: a second call for the same (provider, sub)
// returns the existing row instead of erroring on the unique constraint.
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
const q = `
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (provider, provider_user_id) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name,
avatar_url = EXCLUDED.avatar_url,
updated_at = now()
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("link oauth account: %w", err)
}
return a, nil
}
+26
View File
@@ -62,6 +62,32 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
}
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
// sign-in flow when there's no matching email yet. Same handle-collision
// retry contract as Create.
func (r *UserRepo) CreateOAuth(ctx context.Context, email, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
const q = `
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), name, handle, avatarURL, emailVerified,
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create oauth user: %w", err)
}
return u, nil
}
// FindByID returns the user with the given id, or ErrUserNotFound.
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
return r.findOne(ctx, `WHERE id = $1`, id)
+32 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/worker"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap"
)
@@ -45,6 +46,7 @@ type Server struct {
searchH *handler.SearchHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
oauthH *handler.OAuthHandler
userH *handler.UserHandler
postH *handler.PostHandler
bookmarkH *handler.BookmarkHandler
@@ -121,6 +123,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
userRepo := repository.NewUserRepo(s.pg)
tokenRepo := repository.NewAuthTokenRepo(s.pg)
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
r2, err := repository.NewR2(repository.R2Config{
AccountID: cfg.R2AccountID,
@@ -145,7 +148,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
From: cfg.SMTPFrom,
}, logger)
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
userSvc := service.NewUserService(userRepo, r2)
// Social repositories + services share the same Postgres pool and R2 client.
@@ -162,6 +165,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
s.userRepo = userRepo
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
s.userH = handler.NewUserHandler(userSvc)
// OAuth — wired only when at least one provider is configured. The
// registry skips providers with missing creds, so partial configs (e.g.
// Google only, no Facebook) work out of the box.
providers := oauth.NewRegistry(oauth.Credentials{
GoogleClientID: cfg.GoogleOAuthClientID,
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
FacebookClientID: cfg.FacebookOAuthClientID,
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
})
if len(providers) > 0 {
s.oauthH = handler.NewOAuthHandler(
authSvc, cookieOpts, providers,
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
logger,
)
} else {
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
}
s.postH = handler.NewPostHandler(postSvc)
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
@@ -486,6 +508,15 @@ func (s *Server) setupRouter() {
r.Post("/reset-password", s.authH.ResetPassword)
r.Post("/verify-email", s.authH.VerifyEmail)
// OAuth (Google, Facebook, …). GET endpoints because the browser
// navigates directly to /start (top-level redirect) and the
// provider redirects back to /callback. Both flows set cookies
// and 302 — they never return JSON.
if s.oauthH != nil {
r.Get("/oauth/{provider}/start", s.oauthH.Start)
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/me", s.authH.Me)
+129 -2
View File
@@ -39,6 +39,7 @@ var (
type AuthService struct {
users *repository.UserRepo
tokens *repository.AuthTokenRepo
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
jwt *auth.Issuer
mail mailer.Mailer
logger *zap.Logger
@@ -49,10 +50,12 @@ type AuthService struct {
EmailVerifyTTL time.Duration
}
// NewAuthService wires the dependencies.
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
// the OAuth flows aren't wired (handler then returns 503).
func NewAuthService(
users *repository.UserRepo,
tokens *repository.AuthTokenRepo,
oauthRepo *repository.OAuthAccountRepo,
jwt *auth.Issuer,
mail mailer.Mailer,
logger *zap.Logger,
@@ -61,6 +64,7 @@ func NewAuthService(
return &AuthService{
users: users,
tokens: tokens,
oauth: oauthRepo,
jwt: jwt,
mail: mail,
logger: logger,
@@ -136,12 +140,135 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
// OAuth-only accounts have no password hash. Treat them as a credential
// mismatch so we don't reveal that an OAuth user exists for this email.
if u.PasswordHash == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return s.issueSession(u)
}
// OAuthProfile is the provider-agnostic profile shape consumed by
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
// layer doesn't import the OAuth package (which would create a layering
// cycle once handlers compose both).
type OAuthProfile struct {
Provider string
ProviderUserID string
Email string
EmailVerified bool
Name string
AvatarURL string
}
// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did
// not give us an email and the account has no linkage we could match on.
// Without an email we can't safely create a new user (since later sign-ins
// via email/password would have no way to converge with this account).
var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email")
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
// startup). Handler maps to 503.
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
//
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
// 2. Existing user with same email → link this provider to them, log in.
// 3. Otherwise create a new user (no password, email_verified iff provider
// said so) and link.
//
// In every case the resulting user gets a fresh JWT just like LogIn.
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
if s.oauth == nil {
return nil, ErrOAuthUnavailable
}
// 1. Known linkage.
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
u, err := s.users.FindByID(ctx, a.UserID)
if err != nil {
return nil, err
}
// Refresh denormalised fields on the linkage row (display name /
// avatar may have changed at the provider).
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return nil, err
}
// No linkage yet — we need at least an email to safely converge with an
// existing account or create a new one.
if prof.Email == "" {
return nil, ErrOAuthEmailMissing
}
// 2. Existing user with the same email → link.
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
// 3. Brand new user. Reuse the handle-collision retry loop.
displayName := prof.Name
if displayName == "" {
displayName = strings.SplitN(prof.Email, "@", 2)[0]
}
base := slugForHandle(prof.Email)
var u *model.User
var err error
for attempt := 0; attempt < 8; attempt++ {
handle := base
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), prof.EmailVerified)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
// Race: someone else just claimed this email. Fall back to step 2.
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return s.issueSession(u2)
}
return nil, err
}
if !errors.Is(err, repository.ErrHandleTaken) {
return nil, err
}
}
if err != nil {
return nil, fmt.Errorf("create oauth user: %w", err)
}
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
return s.issueSession(u)
}
// optStr returns nil for empty input, &s otherwise — used to populate
// nullable text columns from possibly-empty profile fields.
func optStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// Me looks up a user by id and re-verifies the JWT's token_version against
// the stored value. Mismatch means the user has logged-out-everywhere or
// changed password since this JWT was issued → reject.