full sign-in-with-github flow built with net/http instead of golang.org/x/oauth2, plus jwt + refresh token issuance
oauth isn't a single api call, it's two full browser redirects plus some server-to-server calls hidden in between:
code query param attachedcode for an access token (this is the only actual "api call" in the traditional sense — everything else is a browser redirect)/user endpoint with that access token to get profile infogithub oauth apps only allow ONE registered callback url per app, so local dev needs its own separate github oauth app (different client_id/secret, callback pointed at localhost) rather than reusing the prod one.
deliberately built this with plain net/http instead of the oauth2 library, specifically to learn calling external apis in go by hand.
exchangeCodeForToken with a single .Exchange(ctx, code) call/user, /user/emails) — that's github-rest-api-specific, not part of the oauth2 spec at allto remove an unused installed package: go mod tidy (scans actual imports across the codebase and prunes anything unused from go.mod/go.sum automatically).
state is a random, unguessable, single-use string. NOT hashed (no db row to protect at rest — it never touches the database, it's cookie-only, transient). needs to be unpredictable, that's it. crypto/rand already gives you that, hashing an already-random value adds nothing.
func generateRandomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}+ and / which need escaping in a url query param, url-safe encoding uses - and _ insteadattacker starts their OWN oauth flow with github using their OWN account, gets a real valid code back for THEIR account. tricks a victim's browser into visiting yourapp.com/auth/github/callback?code=attackers-code (email link, img tag, whatever). if the victim is logged in, your callback would exchange that code and potentially link the attacker's github identity to the victim's session.
func GithubLoginHandler(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
state, err := generateRandomToken()
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
utils.SetCookie(c, "oauth_state", state, 600, cfg)
params := url.Values{}
params.Set("client_id", cfg.GithubClientID)
params.Set("redirect_uri", cfg.GithubRedirectURI)
params.Set("scope", "read:user user:email")
params.Set("state", state)
authURL := "https://github.com/login/oauth/authorize?" + params.Encode()
c.Redirect(http.StatusTemporaryRedirect, authURL)
}
}SetCookie with maxAge=-1 doesn't delete anything server-side immediately — it queues a Set-Cookie header that goes out in the http responseclear state on BOTH the success and failure path of the verification check, not just success — it's meant to be single-use, so make that literal rather than leaving an unused-but-still-valid cookie sitting around.
utils.SetCookie(c, "oauth_state", "", -1, cfg) // clear regardless of match or mismatch
cookieState, err := c.Cookie("oauth_state")
if err != nil || cookieState != params.State {
c.JSON(http.StatusForbidden, types.Error("Invalid state", constants.InvalidState))
return
}github's token endpoint expects classic html-form encoding in the request body (client_id=x&client_secret=y&code=z), NOT json. that's the whole reason for setting Content-Type: application/x-www-form-urlencoded.
url.Values is go's type for url-encoded key-value pairs — used both for query strings AND form-encoded bodies, since the encoding format is identical either way, just placed in different locations. name feels ambiguous but it's the same encoding either way.
var githubHTTPClient = &http.Client{
Timeout: 10 * time.Second,
}
func exchangeCodeForToken(ctx context.Context, cfg *config.Config, code string) (string, error) {
formData := url.Values{}
formData.Set("client_id", cfg.GithubClientID)
formData.Set("client_secret", cfg.GithubClientSecret)
formData.Set("code", code)
formData.Set("redirect_uri", cfg.GithubRedirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://github.com/login/oauth/access_token",
strings.NewReader(formData.Encode()))
if err != nil {
return "", fmt.Errorf("building token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // format of what we're SENDING
req.Header.Set("Accept", "application/json") // format we want BACK
resp, err := githubHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("calling github token endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github token endpoint returned status %d", resp.StatusCode)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("decoding token response: %w", err)
}
// github returns 200 OK even on failure, with an "error" field in the body instead
// of a proper 4xx status — this is oauth-endpoint-specific quirky behavior, must
// check explicitly here. the /user and /user/emails endpoints below do NOT need this,
// they're normal rest endpoints and use real status codes.
if tokenResp.Error != "" {
return "", fmt.Errorf("github oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
if tokenResp.AccessToken == "" {
return "", errors.New("github returned empty access token")
}
return tokenResp.AccessToken, nil
}strings.NewReader(formData.Encode()) — Encode() builds the actual "key=val&key2=val2" string (with proper escaping, same idea as js encodeURIComponent). NewRequestWithContext needs an io.Reader for its body param, not a raw string — NewReader just wraps the string so it satisfies that interface. no json marshaling involved anywhere in this functionhttp.DefaultClient has NO default timeout — a hanging github request would block the handler's goroutine indefinitely. always use a client with an explicit Timeout instead of DefaultClient for any external callclient.Do(req) returning a non-nil err means a TRANSPORT failure (dns, connection refused, timeout) — NOT a 4xx/5xx response. a 404 or 500 is still err == nil, just check resp.StatusCode separately. same "gotcha" as js fetch not rejecting on http error statusesdefer resp.Body.Close() goes right after confirming resp is non-nil, regardless of whether the body ends up getting read/decoded or not — resp.Body is a live connection resource, leaving it open leaks file descriptors / pool slotstype GithubUser struct {
ID int64 `json:"id"` // github's permanent internal user id — use THIS for provider_account_id
Login string `json:"login"` // username, can change, don't use as a stable identifier
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
func fetchGithubUser(ctx context.Context, accessToken string) (*GithubUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
if err != nil {
return nil, fmt.Errorf("building user request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := githubHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("calling github user endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github user endpoint returned status %d", resp.StatusCode)
}
var ghUser GithubUser
if err := json.NewDecoder(resp.Body).Decode(&ghUser); err != nil {
return nil, fmt.Errorf("decoding github user: %w", err)
}
// email can be null/private on /user — fall back to /user/emails for the verified primary
if ghUser.Email == "" {
email, err := fetchGithubPrimaryEmail(ctx, accessToken)
if err != nil {
return nil, fmt.Errorf("fetching github primary email: %w", err)
}
ghUser.Email = email
}
if ghUser.Email == "" {
return nil, errors.New("could not obtain email from github")
}
return &ghUser, nil
}
func fetchGithubPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := githubHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// don't wrap err here — err is nil at this point since Do() already succeeded,
// %w-wrapping a nil error just produces a useless message. use the status code itself.
return "", fmt.Errorf("github email endpoint returned status %d", resp.StatusCode)
}
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
return "", err
}
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
return "", errors.New("no verified primary email found")
}provider_account_idcreating core.users and core.accounts are two separate inserts that represent ONE logical action ("this person signed up via github").
used repository pattern with a WithTx variant, same shape as used elsewhere (e.g. gym project's SubscriptionTxRepository):
type AuthTxRepository interface {
WithTx(tx pgx.Tx) AuthTxRepository
GetUserByEmail(ctx context.Context, email string) (db.CoreUser, error)
CreateUser(ctx context.Context, params db.CreateUserParams) (db.CoreUser, error)
CreateAccount(ctx context.Context, params db.CreateAccountParams) (db.CoreAccount, error)
}
type authTxRepository struct {
queries *db.Queries
pool *pgxpool.Pool
}
func (r *authTxRepository) WithTx(tx pgx.Tx) AuthTxRepository {
return &authTxRepository{queries: r.queries.WithTx(tx), pool: r.pool}
}interface{Begin(ctx) (pgx.Tx, error)}, NOT specifically *pgxpool.Pool — structural typing, *pgxpool.Pool just happens to already have a matching Begin methodfunc GithubCallbackHandler(txRepo repository.AuthTxRepository, repo repository.AuthRepository, cfg *config.Config, pool *pgxpool.Pool) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
var params GithubCallbackHandlerParams
if err := c.ShouldBindQuery(¶ms); err != nil {
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=invalid_request")
return
}
utils.SetCookie(c, "oauth_state", "", -1, cfg)
cookieState, err := c.Cookie("oauth_state")
if err != nil || cookieState != params.State {
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=invalid_state")
return
}
accessToken, err := exchangeCodeForToken(ctx, cfg, params.Code)
if err != nil {
handlerlog.Error(c, "github token exchange failed", err)
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=auth_failed")
return
}
ghUser, err := fetchGithubUser(ctx, accessToken)
if err != nil {
handlerlog.Error(c, "github profile fetch failed", err)
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=auth_failed")
return
}
var user db.CoreUser
err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
qtx := txRepo.WithTx(tx)
existingUser, err := qtx.GetUserByEmail(ctx, ghUser.Email)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("%w: %v", ErrUserLookupFailed, err)
}
newUser, err := qtx.CreateUser(ctx, db.CreateUserParams{
Name: utils.ToNullableText(ghUser.Name),
Email: ghUser.Email,
AvatarUrl: utils.ToNullableText(ghUser.AvatarURL),
})
if err != nil {
return fmt.Errorf("%w: %v", ErrUserCreationFailed, err)
}
_, err = qtx.CreateAccount(ctx, db.CreateAccountParams{
UserID: newUser.ID,
Provider: "github",
ProviderAccountID: strconv.FormatInt(ghUser.ID, 10),
})
if err != nil {
return fmt.Errorf("%w: %v", ErrAccountLinkFailed, err)
}
user = newUser
return nil
}
user = existingUser
return nil
})
if err != nil {
handlerlog.Error(c, "github oauth: user provisioning failed", err)
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=auth_failed")
return
}
if err := generateAccessAndRefreshToken(c, cfg, user, repo); err != nil {
handlerlog.Error(c, "token generation failed", err)
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/auth?error=auth_failed")
return
}
// TODO: check core.tenant_members for this user_id, redirect to /dashboard if found,
// /onboarding if not (covers both brand new users AND returning users who never
// finished onboarding)
c.Redirect(http.StatusTemporaryRedirect, cfg.FrontendURL+"/onboarding")
}
}var (
ErrUserLookupFailed = errors.New("failed to look up user")
ErrUserCreationFailed = errors.New("failed to create user")
ErrAccountLinkFailed = errors.New("failed to link account")
)%w wrap preserves the original db error for logs (err.Error() still has the real postgres error text)errors.Is lets you branch on WHICH kind of failure happened without string parsingvar errMsg string kept in sync by handinvalid_state, auth_failed) — NOT specific internals like "token exchange failed" vs "profile fetch failed"handlerlog.Error)github_token_exchange_failed to a user helps nobody and leaks internal flow details to anyone probing the auth systemc.Redirect, never c.JSONfunc generateAccessAndRefreshToken(c *gin.Context, cfg *config.Config, user db.CoreUser, repo repository.AuthRepository) error {
jti, err := generateRandomToken()
if err != nil {
return fmt.Errorf("generating jti: %w", err)
}
accessClaims := jwt.MapClaims{
"user_id": user.ID,
"email": user.Email,
"role": user.Role,
"jti": jti,
"exp": time.Now().Add(15 * time.Minute).Unix(),
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(cfg.JWTAccessSecret))
if err != nil {
return fmt.Errorf("signing access token: %w", err)
}
refreshClaims := jwt.MapClaims{
"user_id": user.ID,
"jti": jti,
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(cfg.JWTRefreshSecret))
if err != nil {
return fmt.Errorf("signing refresh token: %w", err)
}
// NEVER store the raw token — same principle as passwords. sha256 is fine here
// (not bcrypt) because refresh tokens are long random strings, not guessable —
// bcrypt's slowness exists specifically to resist brute-force guessing of low-entropy
// secrets like passwords, which doesn't apply here.
hash := sha256.Sum256([]byte(refreshTokenString))
tokenHash := hex.EncodeToString(hash[:])
expiresAt := pgtype.Timestamptz{Time: time.Now().Add(30 * 24 * time.Hour), Valid: true}
_, err = repo.CreateRefreshToken(c.Request.Context(), db.CreateRefreshTokenParams{
UserID: user.ID,
TokenHash: tokenHash,
ExpiresAt: expiresAt,
})
if err != nil {
return fmt.Errorf("failed to create refresh token: %w", err)
}
setAuthCookies(refreshTokenString, accessTokenString, cfg, c)
return nil
}jwt.MapClaims without any random field is fully DETERMINISTIC given the same user_id + exptoken_hash (or worse, silent collision if that constraint's missing)this is NOT the same thing as the old session_id pattern from a previous project:
refresh_tokens.id) already uniquely identifies each login/session for revocation purposes-- name: RevokeRefreshToken :exec
UPDATE core.refresh_tokens SET revoked = true WHERE id = $1;
-- name: RevokeAllUserRefreshTokens :exec
-- only for "log out everywhere" / security events, NEVER called on a normal login or
-- normal single-session logout
UPDATE core.refresh_tokens SET revoked = true WHERE user_id = $1 AND revoked = false;-- name: GetRefreshTokenByHash :one
SELECT * FROM core.refresh_tokens
WHERE token_hash = $1 AND revoked = false AND expires_at > now();"no rows" from this query already means "invalid, don't proceed" — nothing left to re-check in application code afterward.
the one real conceptual addition: you're trusting github to have already done the "is this really the account owner" check on their end, and you just trust the signed response they hand back instead of checking a password hash yourself. everything downstream (issuing your own session, jwt/refresh split, revocation) is identical to any other auth scheme.
lookup is currently keyed on EMAIL (GetUserByEmail), not on GetAccountByProvider(provider, provider_account_id).
core.accounts row created for the google linkgithub_client.go / google_client.go split, tokens.go is fully provider-agnostic already) such that fixing this later is a small, contained change — not a rewritesplit by what code DOES, not by provider:
auth/
├── oauth_login.go -- GithubLoginHandler (+ future GoogleLoginHandler)
├── oauth_callback.go -- GithubCallbackHandler (+ future GoogleCallbackHandler)
├── github_client.go -- exchangeCodeForToken, fetchGithubUser, fetchGithubPrimaryEmail, GithubUser struct
├── tokens.go -- generateAccessAndRefreshToken, setAuthCookies (fully provider-agnostic)
├── logout.go
├── me.go
└── refresh.go
tokens.go never needs to change when google's added — token issuance was never provider-specific. google_client.go will mirror github_client.go's three functions.