0

10 min left

0% complete

Blog

Building a Shared OAuth Login Service: A Clean Architecture Approach

Authentication is hard — and when it's duplicated across different parts of your backend, it becomes even harder to maintain. If you're supporting multiple platforms (like mobile and web) or using dif

·10 min read

Authentication is hard — and when it's duplicated across different parts of your backend, it becomes even harder to maintain. If you're supporting multiple platforms (like mobile and web) or using different API styles (GraphQL and REST), you might have run into the problem of repeating the same OAuth logic in multiple places.

This article walks through how to refactor duplicate OAuth authentication code into a single, reusable service that can be shared across both GraphQL mutations and REST endpoints. You’ll learn how to extract complexity, eliminate redundancy, and keep your auth flow consistent — all while making your codebase more maintainable and testable.

Let’s dive in.


Why Share OAuth Logic?

Imagine you’re building an app that supports both web and mobile clients. The mobile app uses GraphQL, while the web app talks to a REST API. Both need to support Google and Apple sign-in.

At first, it's easy to write separate auth handlers for each:

  • A loginWithGoogle mutation in GraphQL
  • A POST /auth/google endpoint in REST

But soon you realize both are doing the same things:

  • Validating the OAuth ID token
  • Finding or creating a user
  • Generating access and refresh tokens
  • Handling edge cases like missing profile data or consent agreements

When logic is duplicated, every bug fix or feature update must be applied twice — increasing the chance of inconsistency and errors.

The solution? Extract shared OAuth logic into a single service.


Architecture Overview

Instead of having each endpoint handle authentication independently, we introduce a dedicated IOAuthLoginService that encapsulates all OAuth login behavior:

┌─────────────────────┐     ┌─────────────────────┐
│  Mobile App         │     │  Web App            │
│  (GraphQL)          │     │  (REST API)         │
└──────────┬──────────┘     └──────────┬──────────┘
           │                           │
           ▼                           ▼
┌─────────────────────┐     ┌─────────────────────┐
│ AuthMutations.cs    │     │ AuthController.cs   │
│ - loginWithGoogle   │     │ - GoogleLogin       │
└──────────┬──────────┘     └──────────┬──────────┘
           │                           │
           └───────────┬───────────────┘
                       ▼
           ┌─────────────────────────┐
           │   IOAuthLoginService    │
           │   (OAuthLoginService)   │
           │                         │
           │ - LoginWithGoogleAsync  │
           │ - LoginWithAppleAsync   │
           └─────────────────────────┘

This design ensures:

  • Single source of truth for authentication logic
  • Consistent behavior across platforms
  • Easier debugging and testing
  • Reduced code duplication

Let’s see what changed in practice.


Before: Duplicated OAuth Logic

In the original code, both endpoints contained nearly identical blocks of logic — around 180–230 lines each.

For example, the GraphQL mutation looked like this:

[AllowAnonymous]
[GraphQLName("loginWithGoogle")]
public async Task<object> LoginWithGoogle(
    string idToken,
    [Service] GiftsIdeasUserManager userManager,
    [Service] IConfiguration configuration,
    [Service] IOptions<GoogleAuthOptions> googleAuthOptions,
    // ... 8 more dependencies
    bool? policiesAgreement = null)
{
    // ~180 lines of validation, user creation, token generation...
}

And the REST controller had its own version:

[HttpPost("google")]
[AllowAnonymous]
public async Task<IActionResult> GoogleLogin([FromBody] GoogleLoginRequest request)
{
    // ~230 lines of the SAME logic...
}

Same token validation. Same user lookup. Same JWT generation. Just copy-pasted.

That's not just messy — it's risky. Any change (like updating token expiry or adding logging) had to be done in two places.


After: Clean, Reusable Service Pattern

Now, both endpoints do one thing: call the shared service and format the result.

GraphQL Mutation (Now ~10 lines)

[AllowAnonymous]
[GraphQLName("loginWithGoogle")]
[GraphQLType(typeof(LoginWithGoogleResultType))]
public async Task<object> LoginWithGoogle(
    string idToken,
    [Service] IOAuthLoginService oauthService,
    [Service] IHttpContextAccessor accessor,
    bool? policiesAgreement = null)
{
    var ipAddress = accessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
    var result = await oauthService.LoginWithGoogleAsync(idToken, policiesAgreement, ipAddress);
    return ConvertOAuthResultToGraphQL(result);
}

REST Endpoint (Now ~15 lines)

[HttpPost("google")]
[AllowAnonymous]
public async Task<IActionResult> GoogleLogin([FromBody] GoogleLoginRequest request)
{
    var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
    var result = await _oauthLoginService.LoginWithGoogleAsync(
        request.IdToken, request.PoliciesAgreement, ipAddress);
    return ConvertOAuthResultToActionResult(result);
}

All the heavy lifting — token validation, user management, token generation — is delegated to the OAuthLoginService.

This means:

  • 🔁 You write logic once
  • 🐞 Fix bugs in one place
  • ✅ Ensure consistent responses

Defining the Service Interface

We start by defining a clean interface in the domain layer, so our core business logic stays decoupled from frameworks and endpoints.

Services/IOAuthLoginService.cs

public interface IOAuthLoginService
{
    Task<OAuthLoginResult> LoginWithGoogleAsync(
        string idToken,
        bool? policiesAgreement,
        string ipAddress,
        CancellationToken cancellationToken = default);

    Task<OAuthLoginResult> LoginWithAppleAsync(
        string identityToken,
        string rawNonce,
        string givenName,
        string familyName,
        string email,
        bool? policiesAgreement,
        string ipAddress,
        CancellationToken cancellationToken = default);
}

This interface makes it clear what the service does and allows us to mock it easily in tests.


The Result Object: Protocol-Agnostic Responses

One key insight is using a rich result object that carries everything needed for any response format.

OAuthLoginResult.cs

public class OAuthLoginResult
{
    public bool Success { get; set; }
    public bool NeedsTermsApproval { get; set; }
    public OAuthPrefillData Prefill { get; set; }
    public User User { get; set; }
    public string AccessToken { get; set; }
    public string RefreshToken { get; set; }
    public int AccessTokenExpiresInSeconds { get; set; }
    public DateTime RefreshTokenExpiresAtUtc { get; set; }
    public string AvatarUrl { get; set; }
    public bool IsInfluencer { get; set; }
    public string DisplayName { get; set; }
    public string ErrorMessage { get; set; }
    public string ErrorMessageArabic { get; set; }
    public string ErrorCode { get; set; }
}

And support data for onboarding:

OAuthPrefillData.cs

public class OAuthPrefillData
{
    public string Email { get; set; }
    public bool EmailVerified { get; set; }
    public string UsernameSuggestion { get; set; }
    public string Name { get; set; }
    public string AvatarUrl { get; set; }
    public string GivenName { get; set; }
    public string FamilyName { get; set; }
    public string[] RequiredFields { get; set; }
}

This single object can be transformed into:

  • A JSON response (REST)
  • A GraphQL union type
  • A mobile SDK payload

No need to repeat business logic.


Implementing the Shared Service

The actual implementation lives in the application layer and orchestrates all dependencies:

Services/OAuthLoginService.cs

Key responsibilities:

  • Validate Google ID tokens using Google’s tokeninfo endpoint
  • Validate Apple identity tokens using JWKS and JWT validation
  • Find or create users in the database
  • Generate secure access and refresh tokens
  • Handle prefill data for incomplete profiles

Example: Supporting multiple client IDs (for web and mobile)

var allowedClientIds = new[] { 
    _googleAuthOptions.MobileClientId, 
    _googleAuthOptions.WebClientId, 
    _googleAuthOptions.OAuthClientId 
}
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal);

This lets the same service work across platforms without changes.

Dependencies include:

  • UserManager – for user creation/login
  • AuthTokenService – for generating tokens
  • IConfiguration / IOptions<T> – for auth settings
  • UserProfileService – for profile handling

Yes, it has many dependencies — but that's okay. OAuth login is inherently complex. The key is keeping that complexity in one well-tested place.


Registering the Service

We register the service in the dependency injection container as scoped:

services.AddScoped<IAuthTokenService, AuthTokenService>();
services.AddScoped<IOAuthLoginService, OAuthLoginService>();

Now any component — GraphQL mutation or REST controller — can depend on IOAuthLoginService and get the same consistent behavior.


Real-World Usage

For Mobile (GraphQL)

mutation {
  loginWithGoogle(idToken: "...", policiesAgreement: true) {
    ... on AuthPayload {
      token
      refreshToken
      user { id email name }
    }
    ... on NeedsTermsApproval {
      status
      prefill { email name }
    }
  }
}

For Web (REST)

POST /api/v1/auth/google
Content-Type: application/json

{
  "idToken": "...",
  "policiesAgreement": true
}

Both return the same underlying data model — just formatted appropriately for the client.


Trade-offs and Best Practices

✅ Benefits

  • Single source of truth: No more syncing bug fixes across files
  • Consistency: Same behavior on web and mobile
  • Maintainability: ~400+ duplicate lines removed
  • Testability: Mock IOAuthLoginService in higher-level tests

⚠️ Trade-offs

  • More abstraction: New developers may need to follow the flow
  • Highly coupled service: Many dependencies due to auth complexity
  • Global impact: A bug affects all platforms using the service
🔍 Best Practice: Keep platform-specific flows separate — such as OAuth redirect handlers (GoogleOAuthController) — because they operate differently than token-based login.

Key Takeaways

  1. Duplication kills maintainability — if you’re doing the same thing in two places, extract it.
  2. Use a result object to decouple business logic from response formats (JSON, GraphQL, etc).
  3. Define interfaces in the domain, implement in the application layer.
  4. Let endpoints be thin — their job is to parse input and format output, not handle business logic.
  5. Accept complexity where it belongs — authentication is complex; centralize it instead of spreading it.

Final Thoughts

Refactoring duplicated OAuth logic into a shared service isn't just about cleaner code — it's about building systems that scale with your team and your product.

By creating a single, well-defined authentication service, you ensure consistency, reduce bugs, and make future improvements possible without fear of breaking one platform while fixing another.

So next time you catch yourself copying auth code from a REST controller to a GraphQL resolver — stop. Extract. Share. Simplify.

Your future self (and your teammates) will thank you.

Contents