Unified JWT Authentication

Introduction And Context

Welcome back! In the previous lesson, you learned how to use mock OAuth providers to simulate real-world authentication flows, and you explored how to link and unlink accounts securely. You also saw how important it is to have strong security practices, such as requiring confirmation before linking accounts and preventing duplicate links. These features help keep user accounts safe and flexible.

Now, let's take the next step in building a robust authentication system. In modern web applications, JWTs (JSON Web Tokens) are a popular way to securely manage user authentication and authorization. JWTs allow your server to issue a signed token to the client after a successful login, which the client can then use to access protected resources. This approach is especially important in OAuth systems, where users might sign in using different providers, such as Google or GitHub.

Before diving in, let's clarify what makes JWTs useful. Unlike session-based authentication, where the server stores session data in memory or a database, JWTs are self-contained tokens that include all necessary user information. The client stores the JWT and sends it with each request, and the server can verify it without looking up session data. This makes JWTs scalable and well-suited for distributed systems and APIs.

In this lesson, you will learn how to integrate JWT tokens into your OAuth system to manage authentication in a unified and secure way. By the end, you will understand how to generate JWTs for different authentication methods, verify them in middleware, and enforce permissions based on the user's authentication context. This will prepare you for hands-on practice and more advanced OAuth integrations.

Generating Unified JWT Tokens

Let's start by looking at how JWT tokens are created in a unified authentication system. The generateTokenWithAuthMethod method is responsible for generating a JWT that contains all the information needed to identify the user and their authentication method.

Here is the method:

public String generateTokenWithAuthMethod(Integer userId, String authMethod) {
    Instant now = Instant.now();
    // OAuth tokens last 7 days, password tokens last 1 day
    long daysToAdd = authMethod.equals("password") ? 1 : 7;
    Instant expiry = now.plus(daysToAdd, ChronoUnit.DAYS);
    
    return JWT.create()
            .withClaim("userId", userId)
            .withClaim("authMethod", authMethod)
            .withIssuedAt(Date.from(now))
            .withExpiresAt(Date.from(expiry))
            .withIssuer("pastebin-api")
            .withAudience("pastebin-client")
            .sign(algorithm);
}

At the top of the JwtUtil class, we use the auth0 library's JWT functionality, which provides the tools we need to create and verify JWTs. The JWT.create() method returns a builder that lets you add claims (data) to the token. The sign() method takes an algorithm that uses a secret key (stored securely on your server) to sign the token. This creates a cryptographically signed token that proves the payload hasn't been tampered with. The algorithm is configured in the constructor using Algorithm.HMAC256(secret), which implements the HMAC-SHA256 signing algorithm.

When you call this method, it creates a token that includes the user's ID and the authentication method (such as password, google, or github). The token's expiry time is set based on the authentication method — password tokens last for one day, while OAuth tokens last for seven days. The method also adds standard JWT claims like iat (issued at) and exp (expires at), plus custom claims for the issuer and audience to identify where the token comes from and who it's intended for.

For example, if a user logs in with a password, the payload might look like this:

{
  "userId": 42,
  "authMethod": "password",
  "iat": 1717257600,
  "exp": 1717344000,
  "iss": "pastebin-api",
  "aud": "pastebin-client"
}

But if a user logs in with Google OAuth, the payload could look like this:

{
  "userId": 99,
  "authMethod": "google",
  "iat": 1717257600,
  "exp": 1717862400,
  "iss": "pastebin-api",
  "aud": "pastebin-client"
}

This approach makes it easy to handle different authentication methods in a consistent way, while also allowing you to set different security rules for each method. The expiration time ensures that tokens don't remain valid forever, reducing the risk if a token is compromised.

⚠️ Implementation Note: In the practice exercises, some JWT implementations use a simplified 24-hour expiry for both authentication methods to keep the code focused on learning JWT verification and permissions. In production systems, you would implement differentiated expiry times (1 day for password, 7 days for OAuth) as shown in the lesson above. The AuthConfig.java file from Unit 1 demonstrates how to centralize these policies.

Deep Dive Into The Unified Authentication Verification

Once a JWT token is generated and sent to the client, you need a way to verify it on every request to protected endpoints. This is where the verifyToken method comes in. Its job is to check the Authorization header for a valid JWT, decode it, and load the user's information.

Here's how the method works:

private AuthOutcome verifyToken(String authorizationHeader) {
    if (authorizationHeader == null || authorizationHeader.isBlank()) {
        return new AuthOutcome(null, null, null, 
            error(HttpStatus.UNAUTHORIZED, "Missing or invalid authorization header"));
    }
    
    String token = authorizationHeader.startsWith("Bearer ") 
        ? authorizationHeader.substring(7) 
        : authorizationHeader;
    
    try {
        DecodedJWT decoded = jwt.verify(token);
        Integer userId = decoded.getClaim("userId").asInt();
        String authMethod = decoded.getClaim("authMethod").asString();
        if (authMethod == null) authMethod = "password";
        
        var userOpt = users.findById(userId);
        if (userOpt.isEmpty()) {
            return new AuthOutcome(null, null, null, 
                error(HttpStatus.NOT_FOUND, "User not found"));
        }
        
        return new AuthOutcome(userOpt.get(), authMethod, decoded, null);
    } catch (Exception e) {
        return new AuthOutcome(null, null, null, 
            error(HttpStatus.UNAUTHORIZED, "Invalid token"));
    }
}

When a request comes in, the method checks for a Bearer token in the header. If the token is missing or invalid, it returns an AuthOutcome with an error response. If the token is valid, it uses jwt.verify(token) to decode and validate the token signature. It then extracts the userId and authMethod claims from the decoded token and fetches the user from the database using the repository's findById method.

The method returns an AuthOutcome record, which encapsulates either a successful authentication (with user, auth method, and decoded JWT) or an error response. This pattern makes it easy to handle authentication failures consistently. The AuthOutcome is defined as:

private record AuthOutcome(User user, String authMethod, 
                          DecodedJWT decoded, 
                          ResponseEntity<ErrorResponse> error) {}

This approach allows controller methods to quickly check for errors and extract authentication information in a type-safe way.

Permission Determination And Checking

Not all users should have the same permissions. For example, an admin might be able to delete any resource, while a regular user can only delete their own. The determinePermissions method decides what permissions a user has based on their role and authentication method.

The User model includes a Role enum (with values like USER and ADMIN) that defines user roles within the system. The permission system uses these roles to determine what actions users can perform.

Here's how it works:

public static List<String> determinePermissions(User user, String authMethod) {
    List<String> basePermissions = new ArrayList<>();
    basePermissions.add("read");
    basePermissions.add("write");
    
    // OAuth users get additional permissions
    if (!authMethod.equals("password")) {
        basePermissions.add("oauth_linked");
    }
    
    // GitHub users get specific GitHub permissions
    if (authMethod.equals("github")) {
        basePermissions.add("github_linked");
    }
    
    // Admin users get all permissions
    if (user.getRole() == Role.ADMIN) {
        basePermissions.add("admin");
        basePermissions.add("delete_any");
    } else {
        basePermissions.add("delete_own");
    }
    
    return basePermissions;
}

If a user logs in with OAuth, they get extra permissions like oauth_linked. GitHub users specifically get github_linked permissions. If the user is an admin, they get admin and delete_any permissions. Regular users get delete_own.

To enforce these permissions in your controllers, you can check if the current user has the required permission before allowing access to a route. For example:

@GetMapping("/protected")
public ResponseEntity<?> protectedRoute(
    @RequestHeader(value = "authorization", required = false) String authorization
) {
    var outcome = verifyToken(authorization);
    if (outcome.error() != null) return outcome.error();
    
    User user = outcome.user();
    String authMethod = outcome.authMethod();
    List<String> permissions = UnifiedAuthUtil.determinePermissions(user, authMethod);
    
    if (!permissions.contains("admin")) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(new ErrorResponse("Insufficient permissions. Required: admin"));
    }
    
    // Route logic here
    return ResponseEntity.ok("Protected data");
}

If a user tries to access a protected route without the right permission, they will see a response like this:

{
  "detail": "Insufficient permissions. Required: admin"
}

This system makes it easy to control access based on both the user's role and how they authenticated.

Using JWT To Check Authentication Status

Now that you have a way to generate and verify JWTs, you can use them to build useful endpoints for your application. The /status endpoint lets clients check the current authentication status, providing detailed information about the user, their permissions, and capabilities.

Here's how the /status endpoint works:

@GetMapping("/status")
public ResponseEntity<?> getStatus(
    @RequestHeader(value = "authorization", required = false) String authorization
) {
    var outcome = verifyToken(authorization);
    if (outcome.error() != null) return outcome.error();
    
    User user = outcome.user();
    String authMethod = outcome.authMethod();
    DecodedJWT decoded = outcome.decoded();
    
    List<String> permissions = UnifiedAuthUtil.determinePermissions(user, authMethod);
    boolean isOAuth = !authMethod.equals("password");
    
    return ResponseEntity.ok(Map.of(
        "user", Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "provider", authMethod
        ),
        "authentication", Map.of(
            "method", authMethod,
            "isOAuth", isOAuth,
            "permissions", permissions,
            "tokenInfo", Map.of(
                "issued", Instant.ofEpochSecond(decoded.getIssuedAt().getTime() / 1000).toString(), // getTime() returns milliseconds, convert to seconds
                "expires", Instant.ofEpochSecond(decoded.getExpiresAt().getTime() / 1000).toString()
            )
        )
    ));
}

When a client calls this endpoint with a valid token, they get a response showing their user info, authentication method, permissions, and token details. The method uses verifyToken(authorization) to extract and verify the JWT, which automatically handles authentication errors. For example:

{
  "user": {
    "id": 42,
    "username": "alice",
    "provider": "password"
  },
  "authentication": {
    "method": "password",
    "isOAuth": false,
    "permissions": ["read", "write", "delete_own"],
    "tokenInfo": {
      "issued": "2024-06-01T12:00:00Z",
      "expires": "2024-06-02T12:00:00Z"
    }
  }
}

This endpoint is particularly useful for client applications that need to display user information, show or hide features based on permissions, or determine when a token is about to expire. The use of Map.of() makes it easy to construct the JSON response structure in a type-safe way.

Refreshing JWT Tokens To Maintain Sessions

The /refresh endpoint allows clients to get a new token before the old one expires. This is useful for keeping users logged in without making them sign in again. Here's how it works:

@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(
    @RequestHeader(value = "authorization", required = false) String authorization
) {
    var outcome = verifyToken(authorization);
    if (outcome.error() != null) return outcome.error();
    
    User user = outcome.user();
    String authMethod = outcome.authMethod();
    
    try {
        // Generate new token with same authentication method
        String newToken = jwt.generateTokenWithAuthMethod(user.getId(), authMethod);
        
        // Determine correct expiry based on authentication method
        String expiresIn = authMethod.equals("password") ? "1 day, 0:00:00" : "7 days, 0:00:00";
        
        return ResponseEntity.ok(Map.of(
            "message", "Token refreshed successfully",
            "token", newToken,
            "authMethod", authMethod,
            "expiresIn", expiresIn
        ));
    } catch (Exception e) {
        System.err.println("Token refresh error: " + e.getMessage());
        return error(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to refresh token");
    }
}

The endpoint uses verifyToken(authorization) to verify the current token and extract the authentication context. It then calls generateTokenWithAuthMethod to create a new token with the same authentication method. The expiry time is determined by checking if the user authenticated via OAuth or password. The try-catch block handles any errors that might occur during token generation, ensuring the client receives a clear error message if something goes wrong.

For example, after calling /refresh, the response might be:

{
  "message": "Token refreshed successfully",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "authMethod": "password",
  "expiresIn": "1 day, 0:00:00"
}

This endpoint is very useful in real-world applications where clients need to keep their sessions active. By refreshing tokens before they expire, you can provide a seamless user experience without forcing users to log in repeatedly.

Summary And Preparation For Hands-On Practice

In this lesson, you learned how to integrate JWT tokens into your OAuth authentication system. You saw how to generate unified tokens for different authentication methods, verify them using a verification method, and extract authentication context from each request. You also explored how to determine and enforce permissions, and how to use JWTs with endpoints that report authentication status and refresh tokens.

These concepts are essential for building secure and flexible authentication systems in modern web applications. You are now ready to put these ideas into practice in the CodeSignal IDE. In the next exercises, you will work directly with the code to generate tokens, protect routes, and manage permissions. Great job making it this far — these skills will serve you well as you continue to build advanced OAuth integrations!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal