Comprehensive Angular authentication module with JWT token management, route guards, CSRF protection, URL redirection, session handling, and clean architecture patterns. Includes login components, auth interceptors, and DDD-based repositories.
npm install @acontplus/ng-authAngular authentication library with comprehensive auth features following Angular 21 best practices and clean architecture principles.
> š New Feature: Multi-Tenant OAuth / Enterprise SSO
> Support for Google Workspace, Microsoft 365, and Azure AD with automatic domain discovery.
> Perfect for SaaS applications with enterprise customers.
``bash`
npm install @acontplus/ng-author
pnpm add @acontplus/ng-auth
- ā
Modern Angular 21 Patterns: Signals, inject(), standalone components
- ā
Clean Architecture: Domain ā Data ā Services (no use cases overhead)
- ā
AuthState Service: Centralized auth state with reactive signals
- ā
Multi-Tenant OAuth: Enterprise SSO with domain discovery (Google Workspace, Azure AD)
- ā
Route Guards: Protection with automatic redirect handling
- ā
Token Management: JWT storage, refresh, and validation
- ā
Auto Token Refresh: Scheduled refresh before expiration
- ā
URL Redirection: Returns users to intended page after login
- ā
Password Management: Reset, change, forgot password flows
- ā
Email Verification: Email verification with resend capability
- ā
MFA/2FA Support: Setup, verify, and disable two-factor auth
- ā
Social Login: Google, Microsoft, GitHub, Facebook, Apple, LinkedIn OAuth
- ā
Domain Discovery: Automatic OAuth provider detection from email
- ā
CSRF Protection: Built-in CSRF token management
- ā
Session Handling: Auto-logout on expiry, 401 interceptor
- ā
Login Component: Ready-to-use Material Design UI with OAuth support
- ā
TypeScript: Full type safety with comprehensive types
`bash`
pnpm add @acontplus/ng-auth
`typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authProviders, authRedirectInterceptor, csrfInterceptor } from '@acontplus/ng-auth';
import { ENVIRONMENT } from '@acontplus/ng-config';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authRedirectInterceptor, // Handles 401 errors
csrfInterceptor, // CSRF protection
]),
),
// Auth services
...authProviders,
// Environment config
{
provide: ENVIRONMENT,
useValue: {
tokenKey: 'auth_token',
refreshTokenKey: 'refresh_token',
loginRoute: 'auth/login',
},
},
],
};
`
`typescript
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from '@acontplus/ng-auth';
export const routes: Routes = [
{
path: 'auth',
loadComponent: () => import('./pages/auth').then((m) => m.AuthPage),
},
{
path: 'dashboard',
loadComponent: () => import('./pages/dashboard').then((m) => m.Dashboard),
canActivate: [authGuard], // š Protected route
},
];
`
`typescript
// dashboard.component.ts
import { Component, inject } from '@angular/core';
import { AuthState } from '@acontplus/ng-auth';
@Component({
selector: 'app-dashboard',
template:
Email: {{ authState.user()?.email }}
@if (authState.isLoading()) {
Loading...
,
})
export class Dashboard {
protected readonly authState = inject(AuthState);
logout() {
this.authState.logout().subscribe();
}
}
`
The main service for all authentication operations. Uses Angular signals for reactive state.
`typescript
import { inject } from '@angular/core';
import { AuthState } from '@acontplus/ng-auth';
const authState = inject(AuthState);
// Reactive state (signals)
authState.isAuthenticated(); // Signal
authState.user(); // Signal
authState.isLoading(); // Signal
authState.mfaRequired(); // Signal
authState.emailVerified(); // Signal
// Authentication methods
authState.login({ email, password, rememberMe }).subscribe();
authState.register({ email, displayName, password }).subscribe();
authState.logout().subscribe();
authState.refreshToken().subscribe();
// Password management
authState.forgotPassword({ email }).subscribe();
authState.resetPassword({ token, newPassword }).subscribe();
authState.changePassword({ currentPassword, newPassword }).subscribe();
// Email verification
authState.verifyEmail({ token }).subscribe();
authState.resendVerificationEmail({ email }).subscribe();
// MFA/2FA
authState.setupMfa().subscribe(); // Returns QR code + backup codes
authState.verifyMfa({ code, email }).subscribe();
authState.disableMfa(code).subscribe();
// Social login
authState.getSocialAuthUrl('google').subscribe();
authState.socialLogin({ provider: 'google', accessToken, idToken }).subscribe();
// State checks
authState.checkAuthentication(); // Returns boolean
authState.setAuthenticated(tokens, rememberMe);
`
`typescript
// app.routes.ts
import { authGuard } from '@acontplus/ng-auth';
const routes: Routes = [
{
path: 'admin',
canActivate: [authGuard],
children: [...],
},
];
`
How it works:
1. User tries to access /admin/settingsauthGuard
2. checks authentication/admin/settings
3. If not authenticated: stores URL ā redirects to login
4. After login: automatically redirects back to
Low-level token storage operations (usually not needed directly):
`typescript
import { inject } from '@angular/core';
import { AuthTokenRepositoryImpl } from '@acontplus/ng-auth';
const tokenRepo = inject(AuthTokenRepositoryImpl);
// Token operations
tokenRepo.getToken(); // Get access token
tokenRepo.getRefreshToken(); // Get refresh token
tokenRepo.saveTokens(tokens, rememberMe);
tokenRepo.clearTokens();
// Validation
tokenRepo.isAuthenticated(); // Check if token is valid
tokenRepo.needsRefresh(); // Check if token needs refresh
// User data extraction from JWT
tokenRepo.getUserData(); // Returns UserData | null
tokenRepo.isRememberMeEnabled(); // Check storage location
`
Auth Redirect Interceptor - Handles 401 errors:
`typescript
import { authRedirectInterceptor } from '@acontplus/ng-auth';
provideHttpClient(withInterceptors([authRedirectInterceptor]));
`
CSRF Interceptor - Adds CSRF tokens:
`typescript
import { csrfInterceptor } from '@acontplus/ng-auth';
provideHttpClient(withInterceptors([csrfInterceptor]));
`
Ready-to-use Material Design login/register UI.
`typescript
import { Login } from '@acontplus/ng-auth';
@Component({
selector: 'app-auth',
imports: [Login],
template: ,`
})
export class AuthPage {}
`typescript
@Component({
template:
[showRegisterButton]="true"
[showRememberMe]="true"
[additionalSigninControls]="extraSigninFields"
[additionalSignupControls]="extraSignupFields"
[footerContent]="footer"
/>
,
})
export class AuthPage {
extraSigninFields = {
companyId: new FormControl('', Validators.required),
};
extraSignupFields = {
role: new FormControl('user', Validators.required),
};
}
`
| Input | Type | Default | Description |
| -------------------------- | --------------------------------- | --------- | ------------------------- |
| title | string | 'Login' | Card title |showRegisterButton
| | boolean | true | Show register toggle |showRememberMe
| | boolean | true | Show remember me checkbox |additionalSigninControls
| | Record | {} | Extra login fields |additionalSignupControls
| | Record | {} | Extra signup fields |additionalSigninFields
| | TemplateRef | null | Custom login template |additionalSignupFields
| | TemplateRef | null | Custom signup template |footerContent
| | TemplateRef | null | Custom footer |
Following Angular 21 style guide and clean architecture:
``
ng-auth/
āāā domain/
ā āāā models/ # DTOs and interfaces
ā āāā repositories/ # Abstract contracts
āāā data/
ā āāā repositories/ # HTTP implementations
āāā repositories/ # Token storage impl
āāā services/
ā āāā auth-state.ts # š Main service
ā āāā auth-url-redirect.ts # URL management
ā āāā csrf-api.ts # CSRF tokens
āāā guards/
ā āāā auth-guard.ts # Route protection
āāā interceptors/
ā āāā auth-redirect-interceptor.ts
ā āāā csrf-interceptor.ts
āāā providers/
ā āāā auth-providers.ts # DI providers
āāā ui/
āāā login/ # Login component
Key decisions:
- ā No use cases layer (unnecessary for a library)
- ā
AuthState consolidates all auth operations
- ā
Signals for reactive state
- ā
Repository pattern for data abstraction
- ā
No .service.ts suffix (Angular 21 style guide)
`typescript
// auth.page.ts
import { Component, inject } from '@angular/core';
import { Login, AuthState } from '@acontplus/ng-auth';
@Component({
selector: 'app-auth',
imports: [Login],
template:
,
styles: [
,
],
})
export class AuthPage {}
`$3
`typescript
// dashboard.page.ts
import { Component, inject } from '@angular/core';
import { AuthState } from '@acontplus/ng-auth';
import { Router } from '@angular/router';@Component({
selector: 'app-dashboard',
template:
Welcome back, {{ user()?.displayName }}! Email: {{ user()?.email }}
@if (user()?.roles?.includes('admin')) {
Admin Panel
}
@if (!emailVerified()) {
,
})
export class Dashboard {
private readonly authState = inject(AuthState);
private readonly router = inject(Router); // Reactive state
user = this.authState.user;
emailVerified = this.authState.emailVerified;
logout() {
this.authState.logout().subscribe(() => {
this.router.navigate(['/auth']);
});
}
resendVerification() {
const email = this.user()?.email;
if (email) {
this.authState.resendVerificationEmail({ email }).subscribe();
}
}
}
`Migration from v1.x
If you're upgrading from the old version:
$3
`typescript
import { LoginUseCase, LogoutUseCase } from '@acontplus/ng-auth';export class MyComponent {
constructor(
private loginUseCase: LoginUseCase,
private logoutUseCase: LogoutUseCase,
) {}
login() {
this.loginUseCase.execute({ email, password }).subscribe();
}
}
`$3
`typescript
import { inject } from '@angular/core';
import { AuthState } from '@acontplus/ng-auth';export class MyComponent {
private authState = inject(AuthState);
login() {
this.authState.login({ email, password }).subscribe();
}
}
`FAQ
Q: Why no use cases layer?
A: For a library, use cases add unnecessary indirection. The
AuthState service provides a clean facade with all operations.Q: Can I use constructor injection instead of inject()?
A: Yes, but
inject() is the Angular 21 recommended approach.Q: How do I customize the login component?
A: Use the component inputs (
additionalSigninControls, templates) or create your own using AuthState directly.Q: What about server-side rendering (SSR)?
A: The library is SSR-compatible. Token operations use
isPlatformBrowser checks.Q: How to handle token expiry?
A: Token refresh is automatic. Use
authRedirectInterceptor for 401 handling.Multi-Tenant OAuth / Enterprise SSO
The library includes comprehensive support for multi-tenant OAuth authentication, ideal for SaaS applications where different organizations use their own identity providers (like Google Workspace, Microsoft Azure AD, etc.).
$3
- ā
Domain Discovery: Automatically detect OAuth provider from email domain
- ā
Tenant Isolation: Support multiple tenants with different OAuth configurations
- ā
SSO Integration: Google Workspace, Microsoft 365, Azure AD, etc.
- ā
Flexible Authentication: Allow both OAuth and password login per tenant
- ā
OAuth Callback Handling: Built-in callback component with CSRF protection
- ā
State Management: Secure OAuth state verification
$3
1. Google Workspace Multi-Tenant
- Each company has their own Google Workspace domain
- Users from
@acme.com use Acme's Google Workspace
- Users from @techcorp.com use TechCorp's Google Workspace2. Microsoft 365 / Azure AD
- Enterprise organizations with Azure AD
- Single sign-on for employees
- Optional password fallback for external users
3. Hybrid Authentication
- OAuth for enterprise customers
- Password login for individual users
- Social login for public users
$3
Your backend needs to implement these endpoints:
`typescript
// Domain Discovery - Maps email domain to OAuth provider
POST /api/auth/domain-discovery
Request: { email: string }
Response: {
provider?: 'google' | 'microsoft' | 'apple' | 'linkedin' | 'github',
tenantId?: string,
domain?: string,
discoveryUrl?: string,
requiresOAuth: boolean,
allowPasswordLogin: boolean
}// Get OAuth Authorization URL with tenant context
GET /api/auth/social/{provider}/url?tenantId=xxx&domain=xxx
Response: {
url: string, // OAuth authorization URL
state: string // CSRF state token
}
// OAuth Callback - Exchange code for tokens
POST /api/auth/social/{provider}/callback
Request: {
provider: string,
code: string,
state: string,
tenantId?: string,
domain?: string
}
Response: {
token: string,
refreshToken: string
}
// Tenant Configuration (optional - for admin UI)
GET /api/auth/tenants
GET /api/auth/tenants/{tenantId}
`$3
Enable automatic domain discovery in the login component:
`typescript
// auth.page.ts
import { Component } from '@angular/core';
import { Login } from '@acontplus/ng-auth';@Component({
selector: 'app-auth',
standalone: true,
imports: [Login],
template:
,
})
export class AuthPage {}
`How it works:
1. User types email:
user@acme.com
2. Library calls /api/auth/domain-discovery with email
3. Backend responds with OAuth provider and tenant info
4. UI shows appropriate login method (OAuth button or password field)$3
Register the OAuth callback component in your routes:
`typescript
// app.routes.ts
import { Routes } from '@angular/router';
import { OAuthCallbackComponent } from '@acontplus/ng-auth';export const routes: Routes = [
{
path: 'auth/callback/:provider',
component: OAuthCallbackComponent,
},
// ... other routes
];
`Configure these redirect URIs in your OAuth provider:
- Google:
https://your-app.com/auth/callback/google
- Microsoft: https://your-app.com/auth/callback/microsoft
- GitHub: https://your-app.com/auth/callback/github$3
For custom implementations, use the AuthState methods directly:
`typescript
import { Component, inject } from '@angular/core';
import { AuthState } from '@acontplus/ng-auth';@Component({
selector: 'app-custom-login',
template:
@if (discovery?.requiresOAuth) {
}
@if (discovery?.allowPasswordLogin) {
}
,
})
export class CustomLogin {
private authState = inject(AuthState);
email = '';
password = '';
discovery: DomainDiscoveryResponse | null = null;
checkDomain(email: string) {
this.email = email;
this.authState.discoverDomain(email).subscribe({
next: (result) => {
this.discovery = result;
},
});
}
loginWithOAuth() {
if (!this.discovery?.provider) return;
// This redirects to OAuth provider
this.authState.startOAuthFlow({
provider: this.discovery.provider,
tenantId: this.discovery.tenantId,
domain: this.discovery.domain,
});
}
loginWithPassword() {
this.authState
.login({
email: this.email,
password: this.password,
})
.subscribe();
}
}
`
If not using the provided callback component:
`typescript
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AuthState } from '@acontplus/ng-auth';
@Component({
selector: 'app-oauth-callback',
template: '
Completing authentication...
', ngOnInit() {
const provider = this.route.snapshot.paramMap.get('provider');
const code = this.route.snapshot.queryParamMap.get('code');
const state = this.route.snapshot.queryParamMap.get('state');
if (provider && code && state) {
this.authState.handleOAuthCallback(provider as SocialProvider, code, state).subscribe({
next: () => {
// Success - AuthState redirects automatically
},
error: (err) => {
console.error('OAuth failed:', err);
},
});
}
}
}
`
Here's a Node.js/Express example for Google Workspace multi-tenant:
`typescript
// Domain discovery endpoint
app.post('/api/auth/domain-discovery', async (req, res) => {
const { email } = req.body;
const domain = email.split('@')[1];
// Check if domain is configured for OAuth
const tenant = await db.tenants.findOne({ domain });
if (tenant?.oauthProvider === 'google') {
return res.json({
provider: 'google',
tenantId: tenant.id,
domain: tenant.domain,
requiresOAuth: true,
allowPasswordLogin: tenant.allowPasswordFallback,
});
}
// Default to password login
res.json({
requiresOAuth: false,
allowPasswordLogin: true,
});
});
// Get OAuth URL
app.get('/api/auth/social/google/url', async (req, res) => {
const { tenantId, domain } = req.query;
const tenant = await db.tenants.findOne({ id: tenantId });
const state = crypto.randomBytes(32).toString('hex');
// Store state for verification
await redis.set(oauth:state:${state}, tenantId, 'EX', 600);
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', tenant.googleClientId);
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/auth/callback/google');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('state', state);
// Tenant hint for Google Workspace
if (domain) {
authUrl.searchParams.set('hd', domain);
}
res.json({
url: authUrl.toString(),
state,
});
});
// OAuth callback
app.post('/api/auth/social/google/callback', async (req, res) => {
const { code, state, tenantId } = req.body;
// Verify state
const storedTenantId = await redis.get(oauth:state:${state});
if (!storedTenantId) {
return res.status(400).json({ error: 'Invalid state' });
}
const tenant = await db.tenants.findOne({ id: tenantId });
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: tenant.googleClientId,
client_secret: tenant.googleClientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: 'https://your-app.com/auth/callback/google',
}),
});
const tokens = await tokenResponse.json();
// Get user info
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: Bearer ${tokens.access_token} },
}).then((r) => r.json());
// Verify domain matches tenant
const userDomain = userInfo.email.split('@')[1];
if (tenant.domain !== userDomain) {
return res.status(403).json({ error: 'Domain mismatch' });
}
// Create or update user
const user = await db.users.upsert({
email: userInfo.email,
displayName: userInfo.name,
tenantId: tenant.id,
emailVerified: userInfo.verified_email,
});
// Generate JWT
const jwt = generateJWT(user);
res.json({
token: jwt,
refreshToken: generateRefreshToken(user),
});
});
`
`typescript
// Tenant configuration for admin panel
interface TenantConfig {
tenantId: string;
domain: string; // e.g., "acme.com"
displayName: string; // e.g., "Acme Corporation"
provider: 'google' | 'microsoft';
// OAuth credentials
clientId: string;
clientSecret: string; // Stored securely on backend
issuer?: string; // For OIDC providers
// Settings
allowedDomains?: string[]; // Additional domains
allowPasswordLogin: boolean;
autoProvision: boolean; // Auto-create users on first login
customParameters?: {
hd?: string; // Google Workspace domain hint
tenant?: string; // Azure AD tenant ID
};
}
`
For local development, use these redirect URIs:
- http://localhost:4200/auth/callback/googlehttp://localhost:4200/auth/callback/microsoft
-
Configure your OAuth apps with these URLs in development mode.
1. CSRF Protection: Always verify the state parameter matches
2. Domain Validation: Verify user's email domain matches tenant configuration
3. HTTPS Only: Enforce HTTPS in production (OAuth providers require it)
4. Secret Storage: Encrypt OAuth client secrets at rest in your database
5. Token Expiry: Set reasonable expiry for OAuth state tokens (5-10 minutes)
6. Scope Limitation: Request only the minimum required OAuth scopes
`typescript
// Tenants table
interface Tenant {
id: string;
domain: string; // "acme.com"
displayName: string; // "Acme Corporation"
oauthProvider: 'google' | 'microsoft' | 'apple' | null;
// OAuth credentials (encrypted at rest!)
googleClientId?: string;
googleClientSecret?: string;
microsoftClientId?: string;
microsoftClientSecret?: string;
azureTenantId?: string; // For Azure AD
// Settings
allowPasswordLogin: boolean;
autoProvisionUsers: boolean; // Auto-create users on first OAuth login
requireEmailVerification: boolean;
createdAt: Date;
updatedAt: Date;
}
// Users table
interface User {
id: string;
email: string;
displayName: string;
tenantId?: string; // Reference to tenant
emailVerified: boolean;
// Track which auth method they used
authProvider: 'password' | 'google' | 'microsoft' | 'github' | 'apple';
// OAuth user ID from provider
oauthProviderId?: string;
createdAt: Date;
lastLoginAt: Date;
}
`
Google Cloud Console Setup
1. Go to console.cloud.google.com
2. Create a new project or select existing
3. Enable Google+ API (or People API)
4. Navigate to Credentials ā Create Credentials ā OAuth 2.0 Client ID
5. Application type: Web application
6. Authorized redirect URIs:
- Production: https://your-app.com/auth/callback/googlehttp://localhost:4200/auth/callback/google
- Development: hd
7. Copy the Client ID and Client Secret
8. For Google Workspace:
- Use the parameter to restrict to specific domainauthUrl.searchParams.set('hd', 'acme.com')
- Example:
Scopes needed:
- openid - Basic authenticationemail
- - User's email addressprofile
- - User's name and photo
Microsoft Azure AD Setup
1. Go to portal.azure.com
2. Navigate to Azure Active Directory ā App registrations
3. Click New registration
4. Name your application
5. Supported account types:
- Single tenant: Only users in your organization
- Multi-tenant: Users in any Azure AD directory
6. Redirect URI:
- Platform: Web
- URI: https://your-app.com/auth/callback/microsoftopenid
7. After creation, go to Certificates & secrets
8. Create a New client secret and save it immediately
9. Go to API permissions ā Add a permission
10. Select Microsoft Graph ā Delegated permissions
11. Add: , email, profile
For multi-tenant:
- Use tenant-specific endpoint: https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorizehttps://login.microsoftonline.com/common/oauth2/v2.0/authorize
- Or use common endpoint:
GitHub OAuth App Setup
1. Go to github.com/settings/developers
2. Click New OAuth App
3. Fill in application details
4. Authorization callback URL: https://your-app.com/auth/callback/github
5. Copy Client ID and generate Client Secret
Scopes needed:
- user:email - Access user's email addressread:user
- - Read user profile information
Error: "Invalid redirect URI"
Cause: The redirect URI in your OAuth request doesn't match what's configured in the provider's settings.
Solutions:
1. Ensure exact match including protocol (http:// vs https://), port, and pathhttp://localhost:4200
2. No trailing slash unless configured that way
3. Check for typos in domain name
4. For local dev, ensure is added to allowed URIs
Error: "Invalid state" or "State mismatch"
Cause: OAuth state token expired or doesn't match.
Solutions:
1. Increase Redis/storage TTL for state tokens (recommended: 600 seconds)
2. User may have taken too long to complete OAuth flow
3. Ensure state is properly stored before redirect
4. Check that state is being retrieved from the same storage
Error: "Domain mismatch" or "Wrong organization"
Cause: User authenticated with an account from a different organization.
Solutions:
1. For Google: Use hd parameter to force specific domain
2. Show clear error message explaining which domain is expected
3. Verify domain matching logic in backend before issuing JWT
4. Consider allowing multiple domains per tenant
Callback hangs or never completes
Cause: Frontend or backend error during token exchange.
Solutions:
1. Check browser console for JavaScript errors
2. Verify backend endpoint is responding
3. Check CORS configuration on backend
4. Ensure OAuth client secret is correct
5. Verify authorization code hasn't expired (valid for ~10 minutes)
`typescript
// Mock domain discovery for testing
describe('OAuth Flow', () => {
let authState: AuthState;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthState, ...authProviders],
});
authState = TestBed.inject(AuthState);
httpMock = TestBed.inject(HttpTestingController);
});
it('should discover Google Workspace tenant', (done) => {
authState.discoverDomain('user@acme.com').subscribe((result) => {
expect(result.provider).toBe('google');
expect(result.tenantId).toBe('acme-123');
expect(result.requiresOAuth).toBe(true);
done();
});
const req = httpMock.expectOne('/api/auth/domain-discovery');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ email: 'user@acme.com' });
req.flush({
provider: 'google',
tenantId: 'acme-123',
requiresOAuth: true,
allowPasswordLogin: false,
});
});
it('should start OAuth flow with tenant context', () => {
spyOn(window.location, 'href', 'set');
authState.startOAuthFlow({
provider: 'google',
tenantId: 'acme-123',
domain: 'acme.com',
});
const req = httpMock.expectOne((req) => req.url.includes('/api/auth/social/google/url'));
req.flush({
url: 'https://accounts.google.com/o/oauth2/v2/auth?...',
state: 'test-state-token',
});
// Verify state stored in session storage
expect(sessionStorage.getItem('oauth_state')).toBe('test-state-token');
expect(sessionStorage.getItem('oauth_tenant_id')).toBe('acme-123');
});
});
``
MIT