Skip to content

OAuth

View as Markdown

This builds on Users & Sessions. You’ll add OAuth login using Authorization Code + PKCE with a provider-agnostic structure, then configure Google first.

  • Go 1.22+
  • Existing sessions and cookies from Users & Sessions
  • A Google OAuth app with client ID/secret
  • Callback URL configured in Google console (for local dev: http://localhost:8080/oauth/google/callback)
/auth
/sessions.go
/cookies.go
/middleware.go
/oauth.go // provider account linking + user creation
/oauth_pkce.go // PKCE verifier + state cookie helpers
/handlers
/oauth.go // oauth start + callback handlers
/pages.go // login page with oauth button
/main.go

Store provider identities separately from users so one user can link multiple providers.

CREATE TABLE oauth_accounts (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(provider, provider_user_id)
);
CREATE INDEX oauth_accounts_user_id_idx ON oauth_accounts(user_id);

Put this in main.go startup:

import (
"os"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
func googleOAuthConfig() *oauth2.Config {
return &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: os.Getenv("GOOGLE_CALLBACK_URL"),
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
}

Environment variables:

  • GOOGLE_CLIENT_ID
  • GOOGLE_CLIENT_SECRET
  • GOOGLE_CALLBACK_URL
  • OAUTH_STATE_SECRET

Put this in auth/oauth_pkce.go:

package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
)
const oauthStateCookieName = "oauth_state"
func NewRandomURLSafe(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func PKCEChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func SignOAuthState(state, verifier string) (string, error) {
secret := os.Getenv("OAUTH_STATE_SECRET")
if secret == "" {
return "", errors.New("missing OAUTH_STATE_SECRET")
}
payload := state + "." + verifier
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payload + "." + sig, nil
}
func VerifyOAuthStateCookie(raw string) (state string, verifier string, err error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return "", "", errors.New("invalid oauth state cookie")
}
payload := parts[0] + "." + parts[1]
secret := os.Getenv("OAUTH_STATE_SECRET")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
return "", "", errors.New("oauth state signature mismatch")
}
return parts[0], parts[1], nil
}
func SetOAuthStateCookie(w http.ResponseWriter, value string) {
http.SetCookie(w, &http.Cookie{
Name: oauthStateCookieName,
Value: value,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
}
func ReadOAuthStateCookie(r *http.Request) (string, error) {
c, err := r.Cookie(oauthStateCookieName)
if err != nil {
return "", fmt.Errorf("missing oauth state cookie: %w", err)
}
return c.Value, nil
}
func ClearOAuthStateCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: oauthStateCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}

Put this in auth/oauth.go:

package auth
import (
"context"
"database/sql"
"errors"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
)
func GetOrCreateUserFromOAuth(ctx context.Context, db *sql.DB, provider, providerUserID, email string) (string, error) {
userID, err := getUserIDByOAuthAccount(ctx, db, provider, providerUserID)
if err != nil {
return "", err
}
if userID != "" {
return userID, nil
}
newUserID, err := gonanoid.New(12)
if err != nil {
return "", err
}
oauthAccountID, err := gonanoid.New()
if err != nil {
return "", err
}
now := time.Now().UTC().Unix()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return "", err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
"INSERT INTO users (id, created_at) VALUES (?, ?)",
newUserID, now,
)
if err != nil {
return "", err
}
_, err = tx.ExecContext(ctx,
"INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, created_at) VALUES (?, ?, ?, ?, ?, ?)",
oauthAccountID, newUserID, provider, providerUserID, email, now,
)
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return newUserID, nil
}
func getUserIDByOAuthAccount(ctx context.Context, db *sql.DB, provider, providerUserID string) (string, error) {
row := db.QueryRowContext(ctx,
"SELECT user_id FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?",
provider, providerUserID,
)
var userID string
err := row.Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return userID, nil
}

Put this in handlers/oauth.go:

package handlers
import (
"encoding/json"
"database/sql"
"fmt"
"io"
"net/http"
auth "github.com/.../auth"
"golang.org/x/oauth2"
)
var allowedOAuthProviders = map[string]struct{}{
"google": {},
}
func readProvider(r *http.Request) (string, bool) {
provider := r.PathValue("provider")
_, ok := allowedOAuthProviders[provider]
return provider, ok
}
func HandleOAuthStart(googleCfg *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provider, ok := readProvider(r)
if !ok {
http.NotFound(w, r)
return
}
if provider != "google" {
http.NotFound(w, r)
return
}
state, err := auth.NewRandomURLSafe(32)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
verifier, err := auth.NewRandomURLSafe(48)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
signedState, err := auth.SignOAuthState(state, verifier)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
auth.SetOAuthStateCookie(w, signedState)
challenge := auth.PKCEChallenge(verifier)
url := googleCfg.AuthCodeURL(
state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
http.Redirect(w, r, url, http.StatusFound)
}
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
func fetchGoogleUserInfo(client *http.Client) (*googleUserInfo, error) {
resp, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("userinfo request failed: %s", string(body))
}
var user googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
func HandleOAuthCallback(db *sql.DB, googleCfg *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provider, ok := readProvider(r)
if !ok {
http.NotFound(w, r)
return
}
if provider != "google" {
http.NotFound(w, r)
return
}
rawCookie, err := auth.ReadOAuthStateCookie(r)
if err != nil {
http.Error(w, "OAuth state missing", http.StatusUnauthorized)
return
}
auth.ClearOAuthStateCookie(w)
expectedState, verifier, err := auth.VerifyOAuthStateCookie(rawCookie)
if err != nil {
http.Error(w, "OAuth state invalid", http.StatusUnauthorized)
return
}
if r.URL.Query().Get("state") != expectedState {
http.Error(w, "OAuth state mismatch", http.StatusUnauthorized)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "OAuth code missing", http.StatusUnauthorized)
return
}
tok, err := googleCfg.Exchange(
r.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", verifier),
)
if err != nil {
http.Error(w, "OAuth login failed", http.StatusUnauthorized)
return
}
googleHTTPClient := googleCfg.Client(r.Context(), tok)
profile, err := fetchGoogleUserInfo(googleHTTPClient)
if err != nil {
http.Error(w, "OAuth login failed", http.StatusUnauthorized)
return
}
if profile.Sub == "" {
http.Error(w, "OAuth account missing user id", http.StatusUnauthorized)
return
}
userID, err := auth.GetOrCreateUserFromOAuth(r.Context(), db, provider, profile.Sub, profile.Email)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
token, err := auth.CreateSession(r.Context(), db, userID)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
auth.SetSessionCookie(w, token)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
}

Put this in handlers/pages.go:

func HandleLoginPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html>
<html>
<body>
<h1>Log in</h1>
<p><a href="/oauth/google">Continue with Google</a></p>
<p><a href="/signup">Use email and password instead</a></p>
</body>
</html>`))
}

If your app is OAuth-only, remove the email/password link.

Put this in main.go:

func main() {
googleCfg := googleOAuthConfig()
mux := http.NewServeMux()
mux.HandleFunc("GET /login", handlers.HandleLoginPage)
mux.HandleFunc("GET /oauth/{provider}", handlers.HandleOAuthStart(googleCfg))
mux.HandleFunc("GET /oauth/{provider}/callback", handlers.HandleOAuthCallback(db, googleCfg))
handler := auth.SessionMiddleware(db)(mux)
http.ListenAndServe(":8080", handler)
}

Do not apply same-origin checks to OAuth callback routes. The provider redirects from a different origin by design.

The structure stays the same for any provider:

  1. Add an oauth2.Config for the provider and wire it into start/callback handlers
  2. Add provider name to allowedOAuthProviders
  3. Add provider callback URL in that provider’s console
  4. Add a login button/link (/oauth/{provider})

Head to Rate Limiting to add a second factor for higher-risk accounts.