OAuth Providers
The provider layer encapsulates all IdP-specific logic: authorization URL construction, token exchange, and response parsing. A factory maps the provider enum to a concrete class.
Factory
// src/lib/external/oauth/factory.ts
class SocialAuthFactory {
static get(provider: SocialProvider): SocialAuthProvider {
switch (provider) {
case 'google': return new GoogleProvider();
case 'microsoft': return new MicrosoftProvider();
default: throw AppError.create('auth.failed', { ... });
}
}
}
The factory is server-only. It creates a fresh instance per request (no singleton caching). The switch acts as an exhaustiveness check at the type level since SocialProvider is a union of 'google' | 'microsoft'.
SocialAuthProvider Interface
Every provider implements two methods:
| Method | Signature | Purpose |
|---|---|---|
getAuthorizationUrl |
(state?: string) => string |
Build the IdP authorization URL with all required params |
exchangeCodeForIdToken |
(code: string) => Promise<string> |
POST to the IdP token endpoint, return the id_token |
Both methods throw AppError on any failure — missing config, network errors, invalid responses, or missing tokens.
Google Provider
src/lib/external/oauth/providers/google.ts
Authorization
Constructs a URL against OAuthConfig.Google.authUri (defaults to https://accounts.google.com/o/oauth2/v2/auth).
| Parameter | Value |
|---|---|
client_id |
OAuthConfig.Google.clientId |
redirect_uri |
OAuthConfig.Google.redirectUris |
response_type |
code |
scope |
openid email profile |
state |
Passed through from caller |
Google authorization does not force prompt or access_type, so Google can apply default account/session behavior without an extra consent interstitial on every login.
Token Exchange
POSTs to OAuthConfig.Google.tokenUri (defaults to https://oauth2.googleapis.com/token).
Request body (application/x-www-form-urlencoded):
client_id,client_secret,code,redirect_uri,grant_type: authorization_code
Expected response shape:
type GoogleTokenResponse = {
access_token?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
token_type?: string;
id_token?: string;
error?: string;
error_description?: string;
};
Returns id_token. Throws if the response contains error or is missing id_token.
Error Surface
| Condition | Error |
|---|---|
| Missing config | config.missing — "Google OAuth configuration missing" |
| Empty code | auth.oauth_failed — "Google authorization code is missing" |
| Network failure | auth.oauth_failed — "Unable to connect to Google OAuth service" |
| Non-200 response | auth.oauth_failed — "Google sign-in could not be completed" |
| Parse failure | auth.oauth_failed — "Invalid response from Google OAuth service" |
Response has error |
auth.oauth_failed — "Google sign-in could not be completed" |
Missing id_token |
auth.oauth_failed — "Google sign-in response is incomplete" |
Microsoft Provider
src/lib/external/oauth/providers/microsoft.ts
Authorization
Uses the Microsoft common endpoint: https://login.microsoftonline.com/common/oauth2/v2.0/authorize.
| Parameter | Value |
|---|---|
client_id |
OAuthConfig.Microsoft.clientId |
response_type |
code |
redirect_uri |
OAuthConfig.Microsoft.redirectUris |
response_mode |
query |
scope |
openid profile email |
prompt |
select_account |
state |
Passed through from caller |
The Microsoft provider does not use access_type: offline and continues to request select_account.
Token Exchange
POSTs to https://login.microsoftonline.com/common/oauth2/v2.0/token.
Request body (application/x-www-form-urlencoded):
client_id,client_secret,code,redirect_uri,grant_type: authorization_code
Expected response shape:
type MicrosoftTokenResponse = {
token_type?: string;
scope?: string;
expires_in?: number;
access_token?: string;
id_token?: string;
error?: string;
error_description?: string;
};
Returns id_token. Throws if the response contains error or is missing either access_token or id_token.
common vs Tenant-Specific
The provider hard-codes common as the tenant, meaning any Microsoft account (personal or organizational) can attempt sign-in. Tenant-level restrictions are applied by the backend during the GraphQL mutation, not at the OAuth layer.
Error Surface
| Condition | Error |
|---|---|
| Missing config | config.missing — "Microsoft OAuth configuration missing" |
| Empty code | auth.oauth_failed — "Microsoft authorization code is missing" |
| Network failure | auth.oauth_failed — "Unable to connect to Microsoft OAuth service" |
| Non-200 response | auth.oauth_failed — "Microsoft sign-in could not be completed" |
| Parse failure | auth.oauth_failed — "Invalid response from Microsoft OAuth service" |
Response has error |
auth.oauth_failed — "Microsoft sign-in could not be completed" |
| Missing token | auth.oauth_failed — "Microsoft sign-in response is incomplete" |
Comparison
| Aspect | Microsoft | |
|---|---|---|
| Auth endpoint | Configurable via env | Hard-coded (login.microsoftonline.com/common) |
| Token endpoint | Configurable via env | Hard-coded |
| Scope | openid email profile |
openid profile email |
| Prompt | Not set (Google default) | select_account |
access_type |
Not set | Not set |
response_mode |
Not set (default) | query |
| Validation check | id_token present |
Both access_token and id_token present |
Adding a New Provider
To add a third provider (e.g., Apple, Okta):
1. Extend the Enum
// src/types/identity/index.ts
enum OAuthProviderEnum {
Google = 'google',
Microsoft = 'microsoft',
Apple = 'apple', // add
}
2. Update SocialProvider
// src/lib/external/oauth/types.ts
type SocialProvider = OAuthProviderEnum.Google | OAuthProviderEnum.Microsoft | OAuthProviderEnum.Apple; // add
3. Create the Provider
// src/lib/external/oauth/providers/apple.ts
import 'server-only';
import { SocialAuthProvider } from '../types';
export class AppleProvider implements SocialAuthProvider {
getAuthorizationUrl(state?: string): string {
// Build Apple authorization URL
}
async exchangeCodeForIdToken(code: string): Promise<string> {
// Exchange code for id_token via Apple's token endpoint
}
}
4. Register in the Factory
// src/lib/external/oauth/factory.ts
case 'apple':
return new AppleProvider();
5. Add Configuration
// src/config/identity/oauth.config.ts
Apple: {
clientId: process.env.APPLE_CLIENT_ID,
// ...
}
6. Add a Feature Flag
Add a raw flag (e.g., enable-apple-signin) and map it in the UI mapper:
showAppleSignin: flags['enable-apple-signin'] === true,
7. Add the GraphQL Mutation
Create the mutation and add a case to IdentityService.handleOAuthCallback.
8. Add the Button
Create a button component under modules/authentication/signin/social/apple/ and render it in SocialLoginModule behind the feature flag.
The server-side callback route (/api/.../callback/route.ts) also needs its provider validation updated.