Skip to content Skip to footer

Secure Authentication System with NHS SSO- Design Template

This document provides a design template for implementing secure authentication with backend-enforced two-factor authentication (2FA) and OAuth2 SSO integration. The pattern demonstrates how to build authentication that cannot be bypassed via direct API calls.

This document was prepared by Yoctobe Technical Team.

Technology Stack:

  • Backend: .NET Core (C#)
  • Frontend: React with Redux
  • Authentication: OAuth2 SSO + Email-based 2FA
  • Database: Entity Framework Core

Architecture Pattern

Core Security Principles

  1. Backend-Enforced 2FA: JWT tokens are only issued AFTER 2FA verification
  2. OAuth2 SSO Bypass: OAuth2 providers handle MFA, so internal 2FA is skipped
  3. No Frontend-Only Security: All security checks happen on the backend
  4. State Management: Proper handling of authentication states in Redux

Part 1: Backend-Enforced 2FA Authentication

Problem Statement

Frontend-only 2FA can be bypassed via Postman or direct API calls. This pattern enforces 2FA at the backend level by only issuing JWT tokens after successful 2FA verification.

Solution Pattern

Step 1: Login Endpoint Returns Pending State

Endpoint: POST /api/v1/auth/signin

Backend Logic:

[HttpPost("signin")]
public async Task<IActionResult> Signin([FromBody] LoginRequestDto loginDto)
{
    // Find user
    var user = await _userManager.FindByEmailAsync(loginDto.Email);
    if (user == null)
    {
        return BadRequest(new { detail = "Invalid login" });
    }

    // Validate password
    var isValid = await _userManager.CheckPasswordAsync(user, loginDto.Password);
    if (!isValid)
    {
        return BadRequest(new { detail = "Invalid login" });
    }

    // Check if 2FA is enabled for this user
    var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);

    if (isTwoFactorEnabled)
    {
        // Generate 2FA code
        var twoFactorToken = await _userManager.GenerateTwoFactorTokenAsync(user, "Email");
        
        // Send code via email service
        await _mailService.SendMailAsync(new EmailDto
        {
            EmailToId = user.Email,
            EmailSubject = "Your Verification Code",
            EmailBody = $"Your two-factor authentication code is: {twoFactorToken}"
        });

        // Return pending state - NO JWT TOKEN YET
        return Ok(new TwoFactorPendingDto
        {
            Email = user.Email,
            RequiresTwoFactor = true
        });
    }

    // If 2FA is not enabled, proceed with regular sign-in
    return await ProcessSignin(loginDto, null);
}

DTO:

public class TwoFactorPendingDto
{
    public string Email { get; set; }
    public bool RequiresTwoFactor { get; set; }
}

LoginResponseDto (for successful login):

public class LoginResponseDto
{
    public string Token { get; set; } // JWT token
    public string Email { get; set; }
    public List<string> Roles { get; set; }
    public string Username { get; set; }
    // ... other user properties
}

Step 2: Verify Code and Issue JWT

Endpoint: POST /api/v1/auth/verifycode

Backend Logic:

[HttpPost("verifycode")]
public async Task<IActionResult> VerifyCode([FromBody] VerifyCodeDto verifyCodeDto)
{
    var user = await _userManager.FindByEmailAsync(verifyCodeDto.Email);
    if (user == null)
    {
        return BadRequest(new { detail = "Invalid email" });
    }

    // Verify 2FA code
    var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, "Email", verifyCodeDto.Code);
    if (!isValid)
    {
        return BadRequest(new { detail = "Invalid code" });
    }

    // NOW generate JWT token (only after 2FA verification)
    var roles = await _userManager.GetRolesAsync(user);
    var token = _tokenService.CreateJWTToken(user, roles.ToList());

    // Return complete login response with JWT
    return Ok(new LoginResponseDto
    {
        Token = token,
        Email = user.Email,
        Roles = roles.ToList(),
        Username = user.UserName
        // ... other user data
    });
}

VerifyCodeDto:

public class VerifyCodeDto
{
    [Required]
    public string Email { get; set; }
    
    [Required]
    public string Code { get; set; }
}

Step 3: Helper Method for Regular Sign-In

private async Task<IActionResult> ProcessSignin(LoginRequestDto loginDto, string? provider)
{
    var user = await _userManager.FindByEmailAsync(loginDto.Email);
    var roles = await _userManager.GetRolesAsync(user);
    var token = _tokenService.CreateJWTToken(user, roles.ToList());

    return Ok(new LoginResponseDto
    {
        Token = token,
        Email = user.Email,
        Roles = roles.ToList(),
        Username = user.UserName
    });
}

Frontend Pattern

Login Action Handler

// actions/userActions.js
export const login = (email, password) => async (dispatch) => {
    try {
        dispatch({ type: USER_LOGIN_REQUEST });

        const config = {
            headers: { 'Content-Type': 'application/json' }
        };

        const { data } = await axios.post(
            '/api/v1/auth/signin',
            { email, password },
            config
        );

        // Check if 2FA required
        if (data.requiresTwoFactor) {
            // Store pending state - NO TOKEN YET
            localStorage.setItem('2faPendingEmail', data.email);
            dispatch({
                type: USER_LOGIN_SUCCESS,
                payload: {
                    email: data.email,
                    requiresTwoFactor: true
                }
            });
            // Redirect to 2FA screen
            return;
        }

        // Regular login - full token received
        dispatch({ type: USER_LOGIN_SUCCESS, payload: data });
        localStorage.setItem('userInfo', JSON.stringify(data));
        dispatch(getMeDetails()); // Fetch user profile
    } catch (error) {
        dispatch({
            type: USER_LOGIN_FAIL,
            payload: error.response?.data?.detail || error.message
        });
    }
};

2FA Verification Screen

// screens/TwoFACodeRequestScreen.js
const handle2FACodeSubmission = async (e) => {
    e.preventDefault();
    setLoading(true);

    try {
        const response = await axios.post('/api/v1/auth/verifycode', {
            email: email,
            code: code
        });

        // Backend returns full LoginResponseDto with JWT
        const userData = response.data;
        
        setLoading(false);
        setTFAMessage("✅ 2FA code verified successfully. Redirecting...");
        
        // Update Redux store with complete user info (including JWT)
        dispatch({
            type: USER_LOGIN_SUCCESS,
            payload: userData
        });
        
        // Store in localStorage
        localStorage.setItem('userInfo', JSON.stringify(userData));
        localStorage.setItem('2fauthenticated', 'true');
        localStorage.removeItem('2faPendingEmail');

        // Fetch user profile details
        await dispatch(getMeDetails());

        // Redirect to dashboard
        setTimeout(() => {
            history.push('/dashboard');
        }, 2000);

    } catch (error) {
        setLoading(false);
        setTFAMessage("❌ 2FA code verification failed.");
    }
};

Redux State Management

// reducers/userReducers.js
export const userLoginReducer = (state = {}, action) => {
    switch (action.type) {
        case USER_LOGIN_REQUEST:
            return { loading: true };
        case USER_LOGIN_SUCCESS:
            return {
                loading: false,
                userInfo: action.payload,
                // Check if 2FA is pending
                requiresTwoFactor: action.payload.requiresTwoFactor || false
            };
        case USER_LOGIN_FAIL:
            return { loading: false, error: action.payload };
        case USER_LOGOUT:
            return {};
        default:
            return state;
    }
};

Component Guard Pattern

// components/ProtectedComponent.jsx
function ProtectedComponent() {
    const userLogin = useSelector(state => state.userLogin);
    const { userInfo, requiresTwoFactor } = userLogin;

    // Don't render if 2FA is pending
    if (!userInfo || requiresTwoFactor) {
        return <div>Please complete 2FA verification...</div>;
    }

    // Don't render if no token
    if (!userInfo.token) {
        return null;
    }

    // Safe to render - user is fully authenticated
    return <div>Protected Content</div>;
}

Part 2: OAuth2 SSO Integration Pattern

Problem Statement

OAuth2 providers (like NHS SSO) already handle multi-factor authentication at the identity provider level. We should skip internal 2FA for OAuth2 logins and issue JWT tokens immediately after successful OAuth callback.

Solution Pattern

Step 1: OAuth2 Configuration

appsettings.json:

{
  "OAuth2SSO": {
    "ClientId": "your-client-id",
    "ClientSecret": "your-client-secret",
    "AuthorizationEndpoint": "https://identity-provider.com/authorize",
    "TokenEndpoint": "https://identity-provider.com/token",
    "UserInformationEndpoint": "https://identity-provider.com/userinfo",
    "CallbackUri": "https://yourapp.com/oauth2/callback",
    "Scope": "openid profile email"
  }
}

Step 2: Backend OAuth Flow

Initiate OAuth (Frontend calls this)

[HttpGet("oauth/initiate")]
public IActionResult InitiateOAuth()
{
    var state = GenerateRandomState();
    HttpContext.Session.SetString("oauth_state", state);

    var authUrl = $"{_config["OAuth2SSO:AuthorizationEndpoint"]}?" +
        $"response_type=code&" +
        $"client_id={_config["OAuth2SSO:ClientId"]}&" +
        $"scope={_config["OAuth2SSO:Scope"]}&" +
        $"redirect_uri={Uri.EscapeDataString(_config["OAuth2SSO:CallbackUri"])}&" +
        $"state={state}";

    return Ok(new { url = authUrl });
}

Handle OAuth Callback

[HttpGet("/oauth2/callback")]
public async Task<IActionResult> OAuthCallback(
    [FromQuery] string code,
    [FromQuery] string state)
{
    // Validate state (CSRF protection)
    var storedState = HttpContext.Session.GetString("oauth_state");
    if (state != storedState)
    {
        return BadRequest("Invalid state parameter");
    }

    // Exchange authorization code for access token
    var tokenResponse = await ExchangeCodeForToken(code);
    var accessToken = tokenResponse["access_token"];

    // Get user information from OAuth provider
    var userInfo = await GetUserInfoFromProvider(accessToken);

    // Find or create user in your system
    var user = await FindOrCreateUser(userInfo);

    // OAuth2 is already secure (handled by identity provider)
    // Skip 2FA and issue JWT immediately
    var roles = await _userManager.GetRolesAsync(user);
    var jwt = _tokenService.CreateJWTToken(user, roles.ToList());

    return Ok(new LoginResponseDto
    {
        Token = jwt,
        Email = userInfo.Email,
        Roles = roles.ToList(),
        Username = userInfo.Username
    });
}

Helper Methods

private async Task<Dictionary<string, string>> ExchangeCodeForToken(string code)
{
    var client = new HttpClient();
    var request = new HttpRequestMessage(HttpMethod.Post, _config["OAuth2SSO:TokenEndpoint"]);
    
    request.Content = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("grant_type", "authorization_code"),
        new KeyValuePair<string, string>("code", code),
        new KeyValuePair<string, string>("redirect_uri", _config["OAuth2SSO:CallbackUri"]),
        new KeyValuePair<string, string>("client_id", _config["OAuth2SSO:ClientId"]),
        new KeyValuePair<string, string>("client_secret", _config["OAuth2SSO:ClientSecret"])
    });

    var response = await client.SendAsync(request);
    var content = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<Dictionary<string, string>>(content);
}

private async Task<UserInfo> GetUserInfoFromProvider(string accessToken)
{
    var client = new HttpClient();
    client.DefaultRequestHeaders.Authorization = 
        new AuthenticationHeaderValue("Bearer", accessToken);

    var response = await client.GetAsync(_config["OAuth2SSO:UserInformationEndpoint"]);
    var content = await response.Content.ReadAsStringAsync();
    return JsonSerializer.Deserialize<UserInfo>(content);
}

private async Task<IdentityUser> FindOrCreateUser(UserInfo oauthUserInfo)
{
    var user = await _userManager.FindByEmailAsync(oauthUserInfo.Email);
    
    if (user == null)
    {
        // Auto-register new user
        user = new IdentityUser
        {
            Email = oauthUserInfo.Email,
            UserName = oauthUserInfo.Email,
            EmailConfirmed = true // OAuth providers verify email
        };
        
        var result = await _userManager.CreateAsync(user);
        if (!result.Succeeded)
        {
            throw new Exception("Failed to create user");
        }

        // Assign default role (e.g., "UnauthorizedUser" - requires admin approval)
        await _userManager.AddToRoleAsync(user, "UnauthorizedUser");
    }

    return user;
}

Step 3: Frontend OAuth Flow

OAuth Login Component

// components/OAuthLogin.component.jsx
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { USER_LOGIN_SUCCESS } from '../constants/userConstants';
import { getMeDetails } from '../actions/userActions';

const OAuthLogin = ({ onLoginSuccess, redirect }) => {
    const dispatch = useDispatch();
    const history = useHistory();
    const [listening, setListening] = useState(false);
    const [loginWindow, setLoginWindow] = useState(null);

    const handleOAuthLogin = async () => {
        try {
            // Get OAuth authorization URL from backend
            const { data } = await axios.get('/api/v1/auth/oauth/initiate');
            
            // Open OAuth provider in popup window
            const popup = window.open(
                data.url,
                'OAuth Login',
                'width=600,height=700,scrollbars=yes'
            );
            
            setLoginWindow(popup);
            setListening(true);
        } catch (error) {
            console.error('OAuth initiation failed:', error);
        }
    };

    useEffect(() => {
        if (!listening) return;

        const handleMessage = async (event) => {
            // Verify origin for security
            if (event.origin !== window.location.origin) return;

            // Check if message contains OAuth token
            if (event.data && event.data.Token) {
                const userInfo = {
                    token: event.data.Token,
                    email: event.data.Email,
                    roles: event.data.Roles,
                    username: event.data.Username
                };

                // Update Redux store
                dispatch({
                    type: USER_LOGIN_SUCCESS,
                    payload: userInfo
                });

                // Store in localStorage
                localStorage.setItem('userInfo', JSON.stringify(userInfo));
                localStorage.setItem('2fauthenticated', 'true');

                // Fetch user profile details
                await dispatch(getMeDetails());

                // Close popup
                if (loginWindow) {
                    loginWindow.close();
                }

                // Call success callback with redirect path
                if (onLoginSuccess) {
                    onLoginSuccess(userInfo, redirect);
                }

                setListening(false);
            }
        };

        window.addEventListener('message', handleMessage);
        return () => window.removeEventListener('message', handleMessage);
    }, [listening, loginWindow, dispatch, onLoginSuccess, redirect]);

    return (
        <button onClick={handleOAuthLogin} className="oauth-login-button">
            Login with OAuth2 SSO
        </button>
    );
};

export default OAuthLogin;

OAuth Callback Handler (Backend sends message to parent)

// This script runs in the OAuth callback popup window
// After backend processes OAuth and returns LoginResponseDto
window.opener.postMessage({
    Token: userData.token,
    Email: userData.email,
    Roles: userData.roles,
    Username: userData.username
}, window.location.origin);

window.close();

Login Screen Integration

// screens/LoginScreen.js
function LoginScreen({ history, location }) {
    const redirect = location.search ? location.search.split('=')[1] : '/dashboard';

    const handleLoginSuccess = (userInfo, redirectPath) => {
        history.push(redirectPath || '/dashboard');
    };

    return (
        <div>
            {/* Email/Password Login */}
            <EmailPasswordLogin />
            
            {/* OAuth SSO Login */}
            <OAuthLogin 
                onLoginSuccess={(userInfo) => handleLoginSuccess(userInfo, redirect)}
                redirect={redirect}
            />
        </div>
    );
}

Part 3: Combined Authentication Flow

Flow Diagram

┌─────────────────┐
│  User Login     │
└────────┬────────┘
         │
    ┌────▼────┐
    │  Method?│
    └────┬────┘
         │
    ┌────┴────┐
    │         │
┌───▼───┐ ┌──▼────┐
│Email/ │ │OAuth2 │
│Pass   │ │ SSO   │
└───┬───┘ └───┬───┘
    │         │
    │    ┌────▼────────────┐
    │    │OAuth Callback   │
    │    │Issue JWT        │
    │    │(No 2FA)         │
    │    └─────────────────┘
    │
┌───▼──────────────┐
│Check 2FA Enabled?│
└───┬──────────────┘
    │
    │ Yes
┌───▼──────────────┐
│Send 2FA Code     │
│Return Pending    │
│(No JWT)          │
└───┬──────────────┘
    │
┌───▼──────────────┐
│User Enters Code  │
└───┬──────────────┘
    │
┌───▼──────────────┐
│Verify Code       │
│Issue JWT         │
└──────────────────┘

Backend Controller Structure

[ApiController]
[Route("api/v1/[controller]")]
public class AuthController : ControllerBase
{
    // Email/Password Login (with 2FA)
    [HttpPost("signin")]
    public async Task<IActionResult> Signin([FromBody] LoginRequestDto dto)
    {
        // Returns TwoFactorPendingDto if 2FA enabled
        // Returns LoginResponseDto if 2FA disabled
    }

    // 2FA Verification
    [HttpPost("verifycode")]
    public async Task<IActionResult> VerifyCode([FromBody] VerifyCodeDto dto)
    {
        // Returns LoginResponseDto with JWT after verification
    }

    // OAuth Initiation
    [HttpGet("oauth/initiate")]
    public IActionResult InitiateOAuth()
    {
        // Returns OAuth authorization URL
    }

    // OAuth Callback
    [HttpGet("/oauth2/callback")]
    public async Task<IActionResult> OAuthCallback([FromQuery] string code, [FromQuery] string state)
    {
        // Returns LoginResponseDto with JWT (no 2FA)
    }
}

Part 4: Frontend State Management

Redux Store Structure

// store.js
const reducer = combineReducers({
    userLogin: userLoginReducer,      // Authentication state
    userDetails: userDetailsReducer,  // User profile
    // ... other reducers
});

User Login State

// Initial state
{
    loading: false,
    userInfo: null,
    requiresTwoFactor: false,
    error: null
}

// After email/password login (2FA required)
{
    loading: false,
    userInfo: { email: "user@example.com", requiresTwoFactor: true },
    requiresTwoFactor: true,
    error: null
}

// After 2FA verification or OAuth login
{
    loading: false,
    userInfo: { 
        token: "jwt-token-here",
        email: "user@example.com",
        roles: ["User"],
        requiresTwoFactor: false
    },
    requiresTwoFactor: false,
    error: null
}

Component Guards

// Pattern for protecting components
function ProtectedComponent() {
    const { userInfo, requiresTwoFactor } = useSelector(state => state.userLogin);

    // Guard 1: Check if 2FA is pending
    if (requiresTwoFactor) {
        return <Redirect to="/2fa" />;
    }

    // Guard 2: Check if user is authenticated
    if (!userInfo || !userInfo.token) {
        return <Redirect to="/login" />;
    }

    // Guard 3: Check role permissions
    if (!userInfo.roles || !userInfo.roles.includes("RequiredRole")) {
        return <div>Access Denied</div>;
    }

    // Safe to render
    return <div>Protected Content</div>;
}

Security Best Practices

Implemented Patterns

  1. Backend-Enforced 2FA: JWT only issued after verification
  2. OAuth2 Trust: Skip 2FA for OAuth2 (provider handles MFA)
  3. State Validation: CSRF protection in OAuth flow
  4. Token Security: JWT tokens with proper expiration
  5. Session Management: Proper cleanup on logout
  6. Error Handling: Clear, actionable error messages

Additional Recommendations

  • Rate limiting on login attempts
  • Account lockout after failed attempts
  • Email notifications for new logins
  • Device fingerprinting
  • IP-based anomaly detection
  • Refresh token rotation
  • Logout from all devices option

Common Pitfalls to Avoid

Don’t Do This

// BAD: Issuing JWT before 2FA
var token = CreateJWTToken(user);
if (user.Requires2FA) {
    Send2FACode(user);
}
return Ok(new { Token = token }); // Token issued without 2FA!

Do This Instead

// GOOD: 2FA first, then JWT
if (user.Requires2FA) {
    Send2FACode(user);
    return Ok(new { RequiresTwoFactor = true }); // No token yet
}
var token = CreateJWTToken(user); // Only after 2FA
return Ok(new { Token = token });

Don’t Do This

// BAD: Frontend-only 2FA check
if (userInfo.requiresTwoFactor) {
    // Still have token, can bypass 2FA
    return <Dashboard />;
}

Do This Instead

// GOOD: Backend never issues token until 2FA verified
if (userInfo.requiresTwoFactor) {
    return <Redirect to="/2fa" />; // No token, must verify
}

Troubleshooting Guide

Issue: “2FA code invalid”

Possible Causes:

  • Server time synchronization (NTP) mismatch
  • Code expired (check UserManager.Options.Tokens.TokenLifespan)
  • Email delivery delay (check spam folder)
  • Code entered incorrectly (case-sensitive)

Solutions:

  • Sync server time with NTP
  • Increase token lifespan if needed
  • Check email service logs
  • Verify code format matches exactly

Issue: “OAuth callback fails”

Possible Causes:

  • Redirect URI mismatch
  • Invalid state parameter
  • ClientId/ClientSecret incorrect
  • CORS issues

Solutions:

  • Verify redirect URI matches exactly in OAuth provider portal
  • Check state parameter validation logic
  • Verify credentials in configuration
  • Check CORS headers in OAuth provider

Issue: “Token not found after 2FA”

Possible Causes:

  • Redux state not updated
  • localStorage not synced
  • Component re-rendering before state update

Solutions:

  • Verify USER_LOGIN_SUCCESS dispatched after 2FA
  • Check localStorage after successful verification
  • Add loading state during transition

Testing Patterns

Backend Testing

[Fact]
public async Task Signin_With2FAEnabled_ReturnsPendingState()
{
    // Arrange
    var user = CreateUserWith2FAEnabled();
    var loginDto = new LoginRequestDto { Email = user.Email, Password = "Password123!" };

    // Act
    var result = await _controller.Signin(loginDto);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var response = Assert.IsType<TwoFactorPendingDto>(okResult.Value);
    Assert.True(response.RequiresTwoFactor);
    Assert.Null(response.Token); // No token yet
}

[Fact]
public async Task VerifyCode_ValidCode_ReturnsJWT()
{
    // Arrange
    var user = CreateUserWith2FAEnabled();
    var code = await Generate2FACode(user);

    // Act
    var result = await _controller.VerifyCode(new VerifyCodeDto { Email = user.Email, Code = code });

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    var response = Assert.IsType<LoginResponseDto>(okResult.Value);
    Assert.NotNull(response.Token); // JWT issued
    Assert.False(response.RequiresTwoFactor);
}

Frontend Testing

describe('Authentication Flow', () => {
    it('should return pending state when 2FA required', async () => {
        const response = await login('user@example.com', 'password');
        expect(response.requiresTwoFactor).toBe(true);
        expect(response.token).toBeUndefined();
    });

    it('should return JWT after 2FA verification', async () => {
        await login('user@example.com', 'password');
        const response = await verifyCode('user@example.com', '123456');
        expect(response.token).toBeDefined();
        expect(response.requiresTwoFactor).toBe(false);
    });

    it('should skip 2FA for OAuth login', async () => {
        const response = await oauthLogin();
        expect(response.token).toBeDefined();
        expect(response.requiresTwoFactor).toBe(false);
    });
});

Key Takeaways for Junior Developers

  1. Security First: Always enforce security at the backend, never trust frontend validation alone
  2. 2FA Flow: Understand the two-step process – login → verify → JWT
  3. OAuth2 Pattern: OAuth2 is already secure, no need for additional 2FA
  4. State Management: Properly handle authentication states in Redux
  5. Error Handling: Always return clear, actionable error messages
  6. Testing: Test both success and failure paths
  7. Documentation: Document authentication flows for team reference

Pattern Reusability

This pattern can be adapted for:

  • Any application requiring backend-enforced 2FA
  • OAuth2 SSO integration with any provider (Google, Microsoft, NHS, etc.)
  • Multi-tenant applications with different auth requirements
  • Applications requiring role-based access control
  • Systems needing audit trails for authentication

Key Adaptation Points:

  • Replace OAuth provider endpoints with your provider’s URLs
  • Adjust 2FA method (SMS, TOTP, etc.) if not using email
  • Customize user roles and permissions for your domain
  • Modify token expiration based on security requirements

Leave a Comment