Implementing OAuth With golang.org/x/oauth2
Tired of building user registration forms and managing password hashes? Wish your users could just log in with their existing Google or GitHub accounts? Welcome to the wonderful world of OAuth!
OAuth is like a digital bouncer that lets users grant your application limited, secure access to their information (like email or profile picture) without ever sharing their actual passwords with you. It's a win-win: enhanced security for everyone, and a smoother, more familiar login experience for your users.
In this guide, we're going to dive deep into implementing OAuth2 in your Golang application using the fantastic golang.org/x/oauth2
package. We'll specifically tackle Google and GitHub, two of the most popular identity providers. By the end, you'll have a fully functional OAuth setup, making your app's authentication process both seamless and secure.
The OAuth Dance: A Four-Step Choreography
Implementing OAuth involves a well-defined sequence of steps. Think of it as a four-part dance:
- Setting up Your Dance Card (OAuth Configurations): Preparing the credentials and settings your app needs to talk to Google or GitHub.
- Sending Invitations (Redirect to OAuth Page): Directing your users to Google or GitHub's login page, where they grant (or deny) permission.
- Receiving RSVP (Callback Handler): Google or GitHub sending your user back to your app with a special code, indicating success (or failure).
- Getting the Guest List (Retrieving User Data): Exchanging that special code for an access token, and then using that token to fetch the user's public profile information (like email, name, avatar).
The Main Event: Setting the Stage
First things first, let's lay out the basic structure of our Go application. This includes our main entry point (main
function) and some crucial structs to hold user data.
package main
import (
"context" // For context.Background()
"crypto/rand" // For generating random state strings
"encoding/base64" // For encoding the state string
"encoding/json" // For decoding JSON responses from OAuth providers
"errors" // For creating custom errors
"fmt" // For formatted I/O
"log/slog" // For structured logging
"net/http" // Go's standard HTTP library
"os" // For accessing environment variables (API keys)
"strings" // For string manipulation (GitHub name parsing)
"time" // For cookie expiration
"github.com/go-chi/chi/v5" // A popular Go HTTP router
"golang.org/x/oauth2" // The core OAuth2 package
"golang.org/x/oauth2/github" // GitHub-specific OAuth2 endpoint configuration
"golang.org/x/oauth2/google" // Google-specific OAuth2 endpoint configuration
// Note: "database/sql" is imported in the original but not used in this example.
// Removed it to keep the example minimal.
)
// User represents our application's internal user model.
type User struct {
ID int
Email string
FirstName string
LastName string
AvatarURL string
}
// GoogleUser maps to the JSON response from Google's user info endpoint.
type GoogleUser struct {
Email string `json:"email"`
FirstName string `json:"given_name"`
LastName string `json:"family_name"`
AvatarURL string `json:"picture"`
}
// GithubUser maps to the JSON response from GitHub's user endpoint.
type GithubUser struct {
Email string `json:"email"` // May be empty if not public
Name string `json:"name"` // Full name
AvatarURL string `json:"avatar_url"`
}
// GetFirstNameLastName attempts to split GitHub's 'Name' field into first and last names.
func (gu *GithubUser) GetFirstNameLastName() (string, string) {
split := strings.Split(gu.Name, " ")
if len(split) == 1 {
return gu.Name, "" // Only one part, assume it's the first name
}
if len(split) == 2 {
return split[0], split[1] // Simple split for two parts
}
// For more than two parts, assume first part is first name, last part is last name
return split[0], split[len(split)-1]
}
func main() {
r := chi.NewRouter()
// Define our OAuth routes:
// /oauth/{provider} -> initiates the OAuth flow
// /oauth/{provider}/callback -> handles the callback from the OAuth provider
r.Route("/oauth", func(r chi.Router) {
r.Route("/{provider}", func(r chi.Router) {
r.Get("/", GetOAuthFlow)
r.Get("/callback", GetOAuthCallback)
})
})
slog.Info("Server starting", "port", ":8080")
http.ListenAndServe(":8080", r)
}
Important Note: For a real application, you'd initialize a database connection and pass it around. Here, we're focusing purely on the OAuth flow.
Chapter 1: Setting Up Your Dance Card (OAuth Configurations)
Before your app can chat with Google or GitHub, you need to tell it who you are. This involves creating oauth2.Config
structs, which hold your application's client ID, client secret, redirect URL, and the specific permissions (scopes) you're requesting.
Where to get Client IDs and Secrets:
- Google: Go to Google Cloud Console, create a new project, navigate to "APIs & Services" -> "Credentials," and create an "OAuth client ID" for a "Web application."
- GitHub: Go to your GitHub settings -> "Developer settings" -> "OAuth Apps" or "GitHub Apps."
It's crucial to store these Client IDs and Client Secrets as environment variables (e.g., GOOGLE_CLIENT_ID
, GITHUB_CLIENT_SECRET
) and NEVER hardcode them in your application.
// Global OAuth config variables. They are initialized once.
var googleOAuthConfig *oauth2.Config
var githubOAuthConfig *oauth2.Config
// Constants for API endpoints to fetch user data after successful authentication.
const (
oauthGoogleUserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo?access_token="
oauthGithubUserURL = "https://api.github.com/user"
oauthGithubUserEmailsURL = "https://api.github.com/user/emails" // Needed for private emails
)
// newGoogleOAuthConfig initializes the OAuth2 configuration for Google.
func newGoogleOAuthConfig() *oauth2.Config {
return &oauth2.Config{
RedirectURL: "http://localhost:8080/oauth/google/callback", // Where Google sends the user back
ClientID: os.Getenv("GOOGLE_CLIENT_ID"), // Get from Google Cloud Console
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), // Get from Google Cloud Console
Scopes: []string{ // Permissions we're asking for
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint, // Google's OAuth2 endpoints
}
}
// newGithubOAuthConfig initializes the OAuth2 configuration for GitHub.
func newGithubOAuthConfig() *oauth2.Config {
return &oauth2.Config{
RedirectURL: "http://localhost:8080/oauth/github/callback", // Where GitHub sends the user back
ClientID: os.Getenv("GITHUB_CLIENT_ID"), // Get from GitHub Developer settings
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), // Get from GitHub Developer settings
Scopes: []string{ // Permissions we're asking for
"read:user", "user:email", // Read public profile and user's email addresses
},
Endpoint: github.Endpoint, // GitHub's OAuth2 endpoints
}
}
Chapter 2: Sending Invitations (HTTP Handlers)
Now, let's create the HTTP handlers that kick off the OAuth flow. When a user clicks "Login with Google" or "Login with GitHub," they'll hit our GetOAuthFlow
endpoint. This handler will then redirect them to the respective OAuth provider's login page.
A crucial security step here is generating an OAuth state cookie. This random string is sent to the OAuth provider and then returned to us in the callback. We verify it to prevent CSRF (Cross-Site Request Forgery) attacks. It's like a secret handshake to ensure the callback is legitimate.
// GetOAuthFlow redirects users to the chosen OAuth provider's login page.
func GetOAuthFlow(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider") // Get provider name from URL (e.g., "google", "github")
var config *oauth2.Config
switch provider {
case "google":
if googleOAuthConfig == nil { // Initialize config if not already done
googleOAuthConfig = newGoogleOAuthConfig()
}
config = googleOAuthConfig
case "github":
if githubOAuthConfig == nil { // Initialize config if not already done
githubOAuthConfig = newGithubOAuthConfig()
}
config = githubOAuthConfig
default:
slog.Warn("Unknown OAuth provider requested", "provider", provider)
http.Error(w, "Unknown OAuth provider", http.StatusBadRequest)
return
}
// Generate a unique state string and set it as a cookie.
// This prevents CSRF attacks.
oauthState := generateOAuthStateCookie(w)
// Construct the URL to the OAuth provider's authorization page
// and redirect the user's browser.
url := config.AuthCodeURL(oauthState)
http.Redirect(w, r, url, http.StatusTemporaryRedirect) // 307 redirect
}
// generateOAuthStateCookie creates a random string and sets it as an HTTP-only cookie.
// This state is used to verify the OAuth callback.
func generateOAuthStateCookie(w http.ResponseWriter) string {
expiration := time.Now().Add(1 * time.Hour) // Cookie expires in 1 hour
b := make([]byte, 16)
_, err := rand.Read(b) // Generate 16 random bytes
if err != nil {
slog.Error("Failed to generate random bytes for OAuth state", "err", err)
// In a real app, you might handle this more robustly,
// but for now, we'll proceed as it's a security rather than functional error.
}
state := base64.URLEncoding.EncodeToString(b) // Encode bytes to a URL-safe string
cookie := http.Cookie{
Name: "oauthstate", // Name of the cookie
Value: state, // The random state string
Expires: expiration,
HttpOnly: true, // Important: Make it HTTP-only to prevent client-side script access
Secure: true, // Important: Only send over HTTPS in production
SameSite: http.SameSiteLaxMode, // Recommended for CSRF protection
}
http.SetCookie(w, &cookie) // Send the cookie to the user's browser
return state
}
Chapter 3: Receiving RSVP (Callback Handlers)
After the user interacts with the OAuth provider, they're redirected back to your application's callback
endpoint. This is where the real magic happens: we verify the state
cookie, exchange the authorization code
for an access token
, and then use that token to fetch the user's profile data.
// GetOAuthCallback handles the callback from OAuth providers (Google/GitHub).
func GetOAuthCallback(w http.ResponseWriter, r *http.Request) {
provider := chi.URLParam(r, "provider")
var user User // Our internal User model
var redirectPath string // Where to redirect the user after processing
var err error
switch provider {
case "google":
user, redirectPath, err = handleGoogleCallback(w, r)
case "github":
user, redirectPath, err = handleGithubCallback(w, r)
default:
slog.Warn("Unknown OAuth provider in callback", "provider", provider)
http.Redirect(w, r, "/login", http.StatusSeeOther) // Redirect to login on unknown provider
return
}
if err != nil {
slog.Error("Error handling OAuth callback", "provider", provider, "err", err)
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusSeeOther) // Redirect to login with error
return
}
// At this point, 'user' is an instance of our internal User struct
// populated with data from the OAuth provider.
// You would typically:
// 1. Check if a user with this email already exists in your database.
// 2. If yes, sign them in (e.g., create a session cookie for them).
// 3. If no, create a new user record in your database and then sign them in.
slog.Info("OAuth callback successful", "provider", provider, "user_email", user.Email)
// For demonstration, we'll just redirect to the determined path.
// In a real app, this redirect would happen after successful login/registration.
http.Redirect(w, r, redirectPath, http.StatusSeeOther)
}
Fetching User Data: Google vs. GitHub
The process of fetching user data differs slightly between providers because their API structures vary.
Google: The Straightforward Path
Google typically provides all the basic user info (email, name, picture) in a single endpoint.
// handleGoogleCallback performs the Google-specific callback logic.
func handleGoogleCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
appUser := User{}
redirectPath := "/dashboard" // Default path after successful login
// 1. Validate the OAuth state cookie to prevent CSRF.
oauthState, err := r.Cookie("oauthstate")
if err != nil {
slog.Error("OAuth state cookie not found for Google callback", "err", err)
return appUser, redirectPath, errors.New("OAuth state cookie not found")
}
if r.FormValue("state") != oauthState.Value {
slog.Error("Invalid OAuth state for Google callback", "expected", oauthState.Value, "got", r.FormValue("state"))
return appUser, redirectPath, errors.New("Invalid OAuth Google state")
}
// 2. Exchange the authorization code for an access token.
// This is the core of the OAuth flow.
token, err := googleOAuthConfig.Exchange(context.Background(), r.FormValue("code"))
if err != nil {
return appUser, redirectPath, fmt.Errorf("error exchanging Google code for token: %w", err)
}
// 3. Use the access token to fetch user profile data from Google's API.
googleUser, err := getGoogleUserData(token.AccessToken)
if err != nil {
return appUser, redirectPath, fmt.Errorf("error getting user data from Google: %w", err)
}
// 4. Map Google's user data to our internal User model.
appUser.FirstName = googleUser.FirstName
appUser.LastName = googleUser.LastName
appUser.Email = googleUser.Email
appUser.AvatarURL = googleUser.AvatarURL
return appUser, redirectPath, nil
}
// getGoogleUserData fetches user profile information from Google's UserInfo API.
func getGoogleUserData(accessToken string) (GoogleUser, error) {
gu := GoogleUser{}
// Make an HTTP GET request to Google's user info endpoint with the access token.
response, err := http.Get(oauthGoogleUserInfoURL + accessToken)
if err != nil {
return gu, fmt.Errorf("failed to make HTTP request to Google user info: %w", err)
}
defer response.Body.Close() // Ensure the response body is closed
if response.StatusCode != http.StatusOK {
return gu, fmt.Errorf("Google user info API returned non-OK status: %d", response.StatusCode)
}
// Decode the JSON response into our GoogleUser struct.
err = json.NewDecoder(response.Body).Decode(&gu)
if err != nil {
return gu, fmt.Errorf("failed to decode Google user info JSON: %w", err)
}
return gu, nil
}
GitHub: The Slightly More Complex Path
GitHub can be a bit trickier because a user's primary email might not be public on their profile. If it's not, we need to make a second API call to a different endpoint (/user/emails
) to fetch their primary email address.
// handleGithubCallback performs the GitHub-specific callback logic.
func handleGithubCallback(w http.ResponseWriter, r *http.Request) (User, string, error) {
appUser := User{}
redirectPath := "/dashboard" // Default path after successful login
// 1. Validate the OAuth state cookie.
oauthState, err := r.Cookie("oauthstate")
if err != nil {
slog.Error("OAuth state cookie not found for GitHub callback", "err", err)
return appUser, redirectPath, errors.New("OAuth state cookie not found")
}
if r.FormValue("state") != oauthState.Value {
slog.Error("Invalid OAuth state for GitHub callback", "expected", oauthState.Value, "got", r.FormValue("state"))
return appUser, redirectPath, errors.New("Invalid OAuth GitHub state")
}
// 2. Exchange the authorization code for an access token.
code := r.URL.Query().Get("code") // Get the code from the URL query parameter
token, err := githubOAuthConfig.Exchange(context.Background(), code)
if err != nil {
return appUser, redirectPath, fmt.Errorf("error exchanging GitHub code for token: %w", err)
}
// 3. Fetch user profile data from GitHub's main user API.
githubUser, err := getGithubUser(token.AccessToken) // This function handles email fallback
if err != nil {
return appUser, redirectPath, fmt.Errorf("error getting user data from GitHub: %w", err)
}
// 4. Map GitHub's user data to our internal User model.
appUser.Email = githubUser.Email
appUser.FirstName, appUser.LastName = githubUser.GetFirstNameLastName() // Use helper for name parsing
appUser.AvatarURL = githubUser.AvatarURL
return appUser, redirectPath, nil
}
// getGithubUser fetches user profile and possibly their primary email from GitHub APIs.
func getGithubUser(accessToken string) (GithubUser, error) {
gu, err := getGithubUserData(accessToken) // First try to get user profile
if err != nil {
return gu, err
}
// If the user's public email is not available on their profile,
// make a separate request to get their primary email.
if gu.Email == "" {
githubEmail, err := getUserEmailFromGithub(accessToken)
if err != nil {
slog.Warn("Could not retrieve primary email from GitHub", "err", err, "github_login", gu.Name)
// Return original user data even if email fetch fails, or handle as an error
} else {
gu.Email = githubEmail
}
}
return gu, nil
}
// getGithubUserData fetches basic user profile information from GitHub's /user endpoint.
func getGithubUserData(accessToken string) (GithubUser, error) {
gu := GithubUser{}
// Create an HTTP request with the Authorization header.
req, err := http.NewRequest(http.MethodGet, oauthGithubUserURL, nil)
if err != nil {
return gu, fmt.Errorf("failed to create HTTP request for GitHub user data: %w", err)
}
// GitHub requires an Authorization header with 'token <ACCESS_TOKEN>'
req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))
res, err := http.DefaultClient.Do(req) // Execute the request
if err != nil {
return gu, fmt.Errorf("failed to execute HTTP request to GitHub user data: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return gu, fmt.Errorf("GitHub user data API returned non-OK status: %d", res.StatusCode)
}
// Decode the JSON response.
if err := json.NewDecoder(res.Body).Decode(&gu); err != nil {
return gu, fmt.Errorf("failed to decode GitHub user data JSON: %w", err)
}
return gu, nil
}
// getUserEmailFromGithub fetches the user's primary email address from GitHub's /user/emails endpoint.
func getUserEmailFromGithub(accessToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet, oauthGithubUserEmailsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create HTTP request for GitHub user emails: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", accessToken))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to execute HTTP request to GitHub user emails: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub user emails API returned non-OK status: %d", res.StatusCode)
}
// GitHub returns a list of email objects. We need to find the one marked as 'primary'.
responseEmails := []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
// Other fields like 'verified', 'visibility' might be present but not needed here.
}{}
if err := json.NewDecoder(res.Body).Decode(&responseEmails); err != nil {
return "", fmt.Errorf("failed to decode GitHub user emails JSON: %w", err)
}
for _, re := range responseEmails {
if re.Primary {
return re.Email, nil // Return the primary email
}
}
return "", errors.New("no primary email found for GitHub user")
}
Conclusion: You've Got the Keys!
Congratulations! You've successfully navigated the OAuth dance. Implementing OAuth in your Golang application for Google and GitHub not only significantly enhances security (no storing user passwords!) but also massively improves the user experience by offering seamless, familiar login options.
You've learned how to:
- Configure OAuth clients for different providers.
- Initiate the OAuth flow by redirecting users.
- Handle callbacks securely by verifying the
state
parameter. - Exchange authorization codes for access tokens.
- Fetch valuable user data (email, name, avatar) from both Google and GitHub, including handling GitHub's slightly unique email retrieval.
This foundation is solid. You can now extend this knowledge to integrate with other OAuth providers like Facebook, LinkedIn, or any custom OAuth2 server. Mastering OAuth in Golang is a powerful skill, ensuring your application is both secure and user-friendly.
What's the next step for your application's authentication journey?