DEV Community

Durgesh Pandey
Durgesh Pandey

Posted on • Edited on

Go Build Your Own OAuth Server in Golang (No More Black Boxes!)

In the developer world, secure access control is paramount. OAuth, or Open Authorization, is a popular framework that empowers users to grant access to their data on one service (resource server) to another service (client application) without revealing their credentials. This post delves into building a basic OAuth server in Golang, focusing on the core principles.

Demystifying the OAuth Flow:

The OAuth flow can be visualized as a four-step handshake between the client application, authorization server, and user:

  1. Client Request: The client application initiates the dance by redirecting the user to the authorization server. This request carries information like the client ID, redirect URI (where to send the user back after authorization), and requested scopes (specific data permissions).
  2. User Authorization: The authorization server presents the user with a consent screen, detailing the requested permissions and the client application. Based on the user's decision (approve or deny), the flow progresses.
  3. Authorization Grant: If the user approves, the authorization server redirects the user back to the client application with an authorization code. This short-lived code acts as a temporary token for the next step.
  4. Access Token Request: The client application uses the authorization code to request an access token from the authorization server. This access token serves as a key for accessing the user's data on the resource server.

Building the Foundation (Simple Server Skeleton):

Let's translate this flow into Golang code. Here's a simplified server skeleton to get us started:

package main

import (
  "encoding/json"
  "fmt"
  "net/http"
  "crypto/rand"
)

// In-memory user store (replace with a database or secure storage)
var users map[string]string = map[string]string{
  "user1": "password1",
}

// Function to verify user credentials (replace with your authentication logic)
func verifyUser(username, password string) bool {
  return users[username] == password // Replace with your logic
}

// Function to generate a random string (for authorization code)
func generateRandomString(n int) string {
  b := make([]byte, n)
  rand.Read(b)
  return fmt.Sprintf("%x", b)
}

// In-memory store for authorization codes (replace with secure storage)
type authCode struct {
    clientID string
    scopes   string
}

var authorizationCodes map[string]authCode = make(map[string]authCode)

// Function to check if user is authenticated (replace with your authentication logic)
func isAuthenticated(r *http.Request) bool {
  // Replace with your logic to check for user session or cookie
  return true // Placeholder for now
}


func main() {
  // to authorize the request and get code
  http.HandleFunc("/authorize", handleAuthorize)

  // for code exchange and tokens
  http.HandleFunc("/token", handleAccessToken)

  fmt.Println("OAuth Server listening on port 8080")
  http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

This code lays the groundwork for our server. We've defined a user store (replace with a secure database solution) and a function to verify user credentials. We'll build upon this base by creating handlers for the different stages of the OAuth flow in the next section.

Expanding Functionality (Step-by-Step):

  1. Client Request Handler (handleAuthorize):

This handler will receive the client's authorization request, including the client ID, redirect URI, and requested scopes. We'll need to validate the request and potentially redirect the user to the login page if not already authenticated. Here's an enhanced version of the handler:

func handleAuthorize(w http.ResponseWriter, r *http.Request) {
  clientID := r.URL.Query().Get("client_id")
  redirectURI := r.URL.Query().Get("redirect_uri")
  scopes := r.URL.Query().Get("scope")

  // Validate client ID, redirect URI, and scopes (replace with your validation logic)
  if clientID == "" || redirectURI == "" || scopes == "" {
    http.Error(w, "Invalid request parameters", http.StatusBadRequest)
    return
  }

  // Check if user is authenticated (replace with your authentication logic)
  if ! isAuthenticated(r) {
    // Redirect to login page
    http.Redirect(w, r, "/login", http.StatusFound)
    return
  }

  // Generate authorization code
  authorizationCode := generateRandomString(32)

  // Store authorization code (replace with secure storage like database)
  authorizationCodes[authorizationCode] = struct {
    clientID string
    scopes   string
  }{clientID: clientID, scopes: scopes}

 // Construct the redirect URI with authorization code (replace with URL encoding)
  redirectURL := fmt.Sprintf("%s?code=%s", redirectURI, authorizationCode)

  // Redirect user back to client with authorization code
  http.Redirect(w, r, redirectURL, http.StatusFound)
}
Enter fullscreen mode Exit fullscreen mode
  1. Access Token Request Handler:

The handleAccessToken handler deals with the client application's request to exchange the authorization code for an access token. This access token acts as the key for the client to interact with the resource server on the user's behalf and access the authorized data.

Here's the code snippet for the handleAccessToken handler:

func validateClientCredentials(clientid string, clientsecret string) bool {
    return clientid != "" && clientsecret != ""
}

func handleAccessToken(w http.ResponseWriter, r *http.Request) {
  grantType := r.URL.Query().Get("grant_type")
  authorizationCode := r.URL.Query().Get("code")
  clientID := r.URL.Query().Get("client_id")
  clientSecret := r.URL.Query().Get("client_secret")

  // Validate request parameters (replace with your logic)
  if grantType != "authorization_code" || authorizationCode == "" || clientID == "" || clientSecret == "" {
    http.Error(w, "Invalid request parameters", http.StatusBadRequest)
    return
  }

  // Validate client credentials (replace with your mechanism)
  if ! validateClientCredentials(clientID, clientSecret) {
    http.Error(w, "Invalid client credentials", http.StatusUnauthorized)
    return
  }

  // Validate authorization code (replace with secure storage)
  authInfo, ok := authorizationCodes[authorizationCode]
  if !ok {
    http.Error(w, "Invalid authorization code", http.StatusBadRequest)
    return
  }

  // Generate access token (replace with secure generation and storage)
  accessToken := generateRandomString(32)

  // ... (add logic to generate refresh token - optional)

  // Respond with access token (replace with appropriate JSON response format)
  response := struct {
        AccessToken string `json:"access_token"`
    TokenType   string `json:"token_type"`
    ExpiresIn   int    `json:"expires_in"`
    AuthInfo    any    `json:"auth_info"`
  }{
    AccessToken: accessToken,
    TokenType:   "bearer",
    ExpiresIn:   3600,
    AuthInfo:    authInfo,
  }

  w.Header().Set("Content-Type", "application/json")
  if err := json.NewEncoder(w).Encode(response); err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }

  // Delete authorization code (optional - replace with logic based on expiry)
  delete(authorizationCodes, authorizationCode)
}
Enter fullscreen mode Exit fullscreen mode

Testing Everything

Writing unit and integration tests is an essential part of developing robust and maintainable code. Unit tests isolate and verify the functionality of individual functions, while integration tests simulate real-world scenarios like the OAuth flow we implemented. These tests serve multiple purposes:

  • Early Bug Detection: Tests help identify issues early in the development cycle, preventing them from propagating to later stages. This saves time and effort compared to debugging production issues.
  • Improved Code Quality: By writing tests, we're forced to think clearly about the expected behavior of our code. This often leads to cleaner, more well-defined functions and a more maintainable codebase.
  • Reduced Reliance on Manual Testing: Unit and integration tests automate repetitive tasks that would otherwise be done manually using tools like Postman. This frees up developer time for other activities and fosters a more efficient development workflow.

Tests act as a safety net, catching errors early and giving us confidence in our code's correctness. They speed up development and improve quality. Our unit and integration tests cover the handlers and the OAuth flow, ensuring functionality as we make changes.

func TestHandleAuthorize_ValidRequest(t *testing.T) {
  clientID := "test-client-id"
  redirectURI := "http://localhost:8080/callback"
  scopes := "read_user_info"

  req, err := http.NewRequest(http.MethodGet, "/authorize", nil)
  if err != nil {
    t.Errorf("Failed to create request: %v", err)
    return
  }

  q := req.URL.Query()
  q.Add("client_id", clientID)
  q.Add("redirect_uri", redirectURI)
  q.Add("scope", scopes)
  req.URL.RawQuery = q.Encode()

  var w *http.RequestRecorder = httptest.NewRecorder()

  handleAuthorize(&w, req)

  // Assert status code and presence of authorization code in response location
  if w.Code != http.StatusFound {
    t.Errorf("Expected status code 302 Found, got %d", w.Code)
  }

  location := w.Header().Get("Location")
  if location == "" {
    t.Error("Missing Location header in response")
  } else if !strings.Contains(location, "code=") {
    t.Error("Authorization code not found in redirect URI")
  }
}
Enter fullscreen mode Exit fullscreen mode
func TestHandleAccessToken_ValidRequest(t *testing.T) {
  clientID := "test-client-id"
  clientSecret := "test-client-secret"
  authorizationCode := "valid_authorization_code"
  accessToken := "generated_access_token"

  // Mock authorization code storage (replace with secure storage)
  authorizationCodes[clientID] = authCode{
     clientID: clientID,
     scopes: "read_user_info",
  }

  // Mock client credential validation (replace with your logic)
  validateClientCredentials = func(id, secret string) bool {
    return id == clientID && secret == clientSecret
  }

  req, err := http.NewRequest(http.MethodPost, "/token", nil)
  if err != nil {
    t.Errorf("Failed to create request: %v", err)
    return
  }

  q := req.URL.Query()
  q.Add("grant_type", "authorization_code")
  q.Add("code", authorizationCode)
  q.Add("client_id", clientID)
  q.Add("client_secret", clientSecret)
  req.URL.RawQuery = q.Encode()

  var w *http.RequestRecorder = httptest.NewRecorder()

  handleAccessToken(w, req)

  // Assert status code and presence of access token in JSON response
  if w.Code != http.StatusOK {
    t.Errorf("Expected status code 200 OK, got %d", w.Code)
  }

  var response struct {
    AccessToken string `json:"access_token"`
  }
  err = json.NewDecoder(w.Body).Decode(&response)
  if err != nil {
    t.Errorf("Failed to decode response body: %v", err)
    return
  }

  if response.AccessToken != accessToken {
    t.Errorf("Expected access token %s, got %s", accessToken, response.AccessToken)
  }
}
Enter fullscreen mode Exit fullscreen mode
func TestOAuthFlow(t *testing.T) {
  // Simulate client authorization request
  clientID := "test-client-id"
  redirectURI := "http://localhost:8080/callback"
  scopes := "read_user_info"

  req, err := http.NewRequest(http.MethodGet, "/authorize", nil)
  if err != nil {
    t.Errorf("Failed to create request: %v", err)
    return
  }

  q := req.URL.Query()
  q.Add("client_id", clientID)
  q.Add("redirect_uri", redirectURI)
  q.Add("scope", scopes)
  req.URL.RawQuery = q.Encode()

  var w *http.RequestRecorder = httptest.NewRecorder()

  handleAuthorize(w, req)

  // Assert status code and presence of authorization code in Location header
  if w.Code != http.StatusFound {
    t.Errorf("Expected status code 302 Found, got %d", w.Code)
  }

  location := w.Header().Get("Location")
  if location == "" {
    t.Error("Missing Location header in response")
  } else if !strings.Contains(location, "code=") {
    t.Error("Authorization code not found in redirect URI")
  }

  // Extract authorization code from redirect URI (simulate client receiving the code)
  authorizationCode := strings.SplitAfter(location, "code=")[1]

  // Simulate client access token request
  clientSecret := "test-client-secret"
  req, err = http.NewRequest(http.MethodPost, "/token", nil)
  if err != nil {
    t.Errorf("Failed to create request: %v", err)
    return
  }

  q = req.URL.Query()
  q.Add("grant_type", "authorization_code")
  q.Add("code", authorizationCode)
  q.Add("client_id", clientID)
  q.Add("client_secret", clientSecret)
  req.URL.RawQuery = q.Encode()

  var w2 *http.RequestRecorder = httptest.NewRecorder()

  handleAccessToken(w2, req)

  // Assert status code and presence of access token in JSON response
  if w2.Code != http.StatusOK {
    t.Errorf("Expected status code 200 OK, got %d", w2.Code)
  }

  var response struct {
    AccessToken string `json:"access_token"`
  }
  err = json.NewDecoder(w2.Body).Decode(&response)
  if err != nil {
    t.Errorf("Failed to decode response body: %v", err)
    return
  }

  // Additional assertions based on your access token usage (e.g., validate it with a resource server)
  if response.AccessToken == "" {
    t.Error("Missing access token in response")
  }
  // ... (Optional: Validate access token with a mock resource server)
}
Enter fullscreen mode Exit fullscreen mode

EOF

In conclusion, this post has equipped you with the fundamentals of building a basic OAuth server in Golang. We've walked through the authorization flow, implemented key handlers, and incorporated tests for a solid foundation. Remember, this is a springboard for further exploration. Take action by experimenting with the code, delving deeper into OAuth specifications, and leveraging this knowledge to build secure authorization for your applications.

I'd love to hear your thoughts! Share your feedback, questions, or interesting OAuth projects in the comments below. Feel free to like and share this post if you found it valuable.

Top comments (0)