github oauth full flow

github oauth from scratch with std lib

full sign-in-with-github flow built with net/http instead of golang.org/x/oauth2, plus jwt + refresh token issuance

the two-hop redirect shape

oauth isn't a single api call, it's two full browser redirects plus some server-to-server calls hidden in between:

the flow

  1. user clicks "sign in with github" -> browser does a full page redirect to github's authorize url (NOT a fetch call, the whole tab navigates away)
  2. github shows its own consent screen
  3. user authorizes -> github redirects the browser BACK to your registered redirect_uri, with a code query param attached
  4. your backend's callback route receives this as a normal GET request
  5. your backend calls github SERVER-TO-SERVER to exchange code for an access token (this is the only actual "api call" in the traditional sense — everything else is a browser redirect)
  6. your backend calls github's /user endpoint with that access token to get profile info
  7. your backend does its own user lookup/creation, issues its OWN jwt/refresh tokens, and redirects the browser one more time to your frontend

redirect_uri vs homepage url

dev vs prod

github 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.


why not golang.org/x/oauth2

deliberately built this with plain net/http instead of the oauth2 library, specifically to learn calling external apis in go by hand.

what x/oauth2 would and wouldn't replace

cleanup

to 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 param — the actual csrf protection

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.

generating state

copy
func generateRandomToken() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(b), nil
}

the attack this prevents

attacker 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.

why state blocks it

storage rules

login handler

copy
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)
    }
}

clear on both paths

clear 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.

copy
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
}

the code exchange — std lib, form-encoded not json

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.

copy
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
}

things worth naming individually


fetching profile data

copy
type 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")
}

id vs login


why user creation + account linking needs a transaction

creating core.users and core.accounts are two separate inserts that represent ONE logical action ("this person signed up via github").

the problem without a transaction

AuthTxRepository

used repository pattern with a WithTx variant, same shape as used elsewhere (e.g. gym project's SubscriptionTxRepository):

copy
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}
}

why pgx.BeginFunc

full callback handler

copy
func 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(&params); 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")
    }
}

sentinel errors

copy
var (
    ErrUserLookupFailed   = errors.New("failed to look up user")
    ErrUserCreationFailed = errors.New("failed to create user")
    ErrAccountLinkFailed  = errors.New("failed to link account")
)

error response design

redirect on every path


generating and storing the actual session tokens

copy
func 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
}

why jti matters

jti vs session_id

this is NOT the same thing as the old session_id pattern from a previous project:

logout revokes one row

copy
-- 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;

validity check in the query

copy
-- 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.

index token_hash


jwt vs session-based auth, the actual tradeoff

session-based

jwt-based (what this project uses)

how this project bridges the gap


oauth is normal auth, minus password verification

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.


known gap, deliberately deferred

lookup is currently keyed on EMAIL (GetUserByEmail), not on GetAccountByProvider(provider, provider_account_id).


file organization

split by what code DOES, not by provider:

copy
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.