Skip to content
Asif Sadat
All writing
EngineeringSep 20266 min read

One interface, two OAuth providers, and a third one I haven't had to build yet

Designing the OAuth provider abstraction behind LinkedIn and Google sign-in for a CV platform, and why the next provider should cost almost nothing to add.

I designed the OAuth provider abstraction behind PrismaCV's LinkedIn and Google sign-in: the interface every provider implements, the LinkedIn side of it, the guard, and how the module wires it together. The account-linking logic underneath it lives in a shared service the whole team touches, and that split is exactly the point of this post: the interface is the thing that has to be right, because everything downstream of it stops caring which provider you used.

The shape every provider has to agree to

LinkedIn and Google don't return the same profile shape. Different field names, different nesting, different guarantees about what's actually populated. Handle that difference anywhere near the business logic and every feature downstream (account linking, welcome emails, avatar display) ends up branching on provider name to know which fields to trust.

So the boundary sits as early as it can. One interface:

interface IOAuthProvider<TProfile = unknown> {
  getProviderName(): string;
  validateProfile(profile: TProfile): OAuthProfile;
}

OAuthProfile is the normalized shape: provider, providerId, email, name, picture. Everything past this point in the system (account linking, user creation, token issuance) reads that one shape and has never heard of LinkedIn or Google specifically. LinkedInOAuthProvider and GoogleOAuthProvider both implement it, each responsible for nothing but translating its own provider's mess into that one contract, including rejecting a profile that's missing an id or an email before it goes any further.

LinkedIn OAuthpassport-linkedin-oauth2Google OAuthpassport-google-oauth20LinkedInStrategyboots even when unconfiguredGoogleStrategysame PassportStrategy contractLinkedInOAuthProviderimplements IOAuthProvider<Profile>validateProfile(): OAuthProfileGoogleOAuthProviderimplements IOAuthProvider<Profile>validateProfile(): OAuthProfileOAuthProfileprovider, providerId, email, name, pictureevery provider normalizes to exactly this+ next providerzero changes elsewhereOAuthService.authenticate()find by provider, then by email: link or createUser + token pairissued the same way regardless of provider

The dashed box is not built. It is what adding a third provider looks like: one class, one strategy, one line in the module. Nothing below the OAuthProfile line has to change.

The decision that isn't visible in the interface

The interesting failure mode for an OAuth integration isn't the happy path, it's what happens when the environment variables aren't set yet. A naive Passport strategy throws at bootstrap if its client id is missing, which means a missing LINKEDIN_CLIENT_SECRET in a staging environment takes down every other route in the app, including the ones with nothing to do with LinkedIn.

LinkedInStrategy boots with placeholder credentials when the real ones aren't configured, tracks a configured boolean, and logs a warning instead of crashing. The route still registers. A request to it will fail cleanly later, at the point where it actually needs the missing config, instead of taking the whole module down before a single request has been served. That's a small decision that only matters on the one day someone forgets to set a secret in a new environment, which is exactly the day you don't want it to matter.

Why the interface is the part that has to be right

The module only exports OAuthService, not the individual providers or strategies. Nothing outside the OAuth module is allowed to know LinkedIn and Google exist as concrete types; it only knows there's a service that authenticates an OAuthProfile and returns a user and a token pair. That's what makes the dashed box in the diagram cheap instead of theoretical: a third provider means writing one class that implements IOAuthProvider, one strategy that calls it, and one line registering both in the module. The account-linking rules, the token issuance, the controller shape: none of it moves, because none of it was ever written against LinkedIn or Google specifically. It was written against the shape.

That's the actual test of whether an abstraction is future-proof: not whether you can imagine a second case, but whether adding it touches anything you already trusted.