10 min left
0% complete
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
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
loginWithGooglemutation in GraphQL - A
POST /auth/googleendpoint 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
tokeninfoendpoint - 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/loginAuthTokenService– for generating tokensIConfiguration/IOptions<T>– for auth settingsUserProfileService– 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
IOAuthLoginServicein 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
- Duplication kills maintainability — if you’re doing the same thing in two places, extract it.
- Use a result object to decouple business logic from response formats (JSON, GraphQL, etc).
- Define interfaces in the domain, implement in the application layer.
- Let endpoints be thin — their job is to parse input and format output, not handle business logic.
- 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.