@acontplus/ng-auth 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -135
- package/fesm2022/acontplus-ng-auth.mjs +21 -8
- package/fesm2022/acontplus-ng-auth.mjs.map +1 -1
- package/package.json +4 -3
- package/types/acontplus-ng-auth.d.ts +3 -0
package/README.md
CHANGED
|
@@ -56,13 +56,13 @@ export const appConfig: ApplicationConfig = {
|
|
|
56
56
|
provideHttpClient(
|
|
57
57
|
withInterceptors([
|
|
58
58
|
authRedirectInterceptor, // Handles 401 errors
|
|
59
|
-
csrfInterceptor,
|
|
60
|
-
])
|
|
59
|
+
csrfInterceptor, // CSRF protection
|
|
60
|
+
]),
|
|
61
61
|
),
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
// Auth services
|
|
64
64
|
...authProviders,
|
|
65
|
-
|
|
65
|
+
|
|
66
66
|
// Environment config
|
|
67
67
|
{
|
|
68
68
|
provide: ENVIRONMENT,
|
|
@@ -86,11 +86,11 @@ import { authGuard } from '@acontplus/ng-auth';
|
|
|
86
86
|
export const routes: Routes = [
|
|
87
87
|
{
|
|
88
88
|
path: 'auth',
|
|
89
|
-
loadComponent: () => import('./pages/auth').then(m => m.AuthPage),
|
|
89
|
+
loadComponent: () => import('./pages/auth').then((m) => m.AuthPage),
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
92
|
path: 'dashboard',
|
|
93
|
-
loadComponent: () => import('./pages/dashboard').then(m => m.Dashboard),
|
|
93
|
+
loadComponent: () => import('./pages/dashboard').then((m) => m.Dashboard),
|
|
94
94
|
canActivate: [authGuard], // 👈 Protected route
|
|
95
95
|
},
|
|
96
96
|
];
|
|
@@ -108,11 +108,11 @@ import { AuthState } from '@acontplus/ng-auth';
|
|
|
108
108
|
template: `
|
|
109
109
|
<h1>Welcome, {{ authState.user()?.displayName }}!</h1>
|
|
110
110
|
<p>Email: {{ authState.user()?.email }}</p>
|
|
111
|
-
|
|
111
|
+
|
|
112
112
|
@if (authState.isLoading()) {
|
|
113
113
|
<p>Loading...</p>
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
|
|
116
116
|
<button (click)="logout()">Logout</button>
|
|
117
117
|
`,
|
|
118
118
|
})
|
|
@@ -139,10 +139,10 @@ const authState = inject(AuthState);
|
|
|
139
139
|
|
|
140
140
|
// Reactive state (signals)
|
|
141
141
|
authState.isAuthenticated(); // Signal<boolean>
|
|
142
|
-
authState.user();
|
|
143
|
-
authState.isLoading();
|
|
144
|
-
authState.mfaRequired();
|
|
145
|
-
authState.emailVerified();
|
|
142
|
+
authState.user(); // Signal<UserData | null>
|
|
143
|
+
authState.isLoading(); // Signal<boolean>
|
|
144
|
+
authState.mfaRequired(); // Signal<boolean>
|
|
145
|
+
authState.emailVerified(); // Signal<boolean>
|
|
146
146
|
|
|
147
147
|
// Authentication methods
|
|
148
148
|
authState.login({ email, password, rememberMe }).subscribe();
|
|
@@ -189,6 +189,7 @@ const routes: Routes = [
|
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
**How it works:**
|
|
192
|
+
|
|
192
193
|
1. User tries to access `/admin/settings`
|
|
193
194
|
2. `authGuard` checks authentication
|
|
194
195
|
3. If not authenticated: stores URL → redirects to login
|
|
@@ -205,38 +206,36 @@ import { AuthTokenRepositoryImpl } from '@acontplus/ng-auth';
|
|
|
205
206
|
const tokenRepo = inject(AuthTokenRepositoryImpl);
|
|
206
207
|
|
|
207
208
|
// Token operations
|
|
208
|
-
tokenRepo.getToken();
|
|
209
|
-
tokenRepo.getRefreshToken();
|
|
209
|
+
tokenRepo.getToken(); // Get access token
|
|
210
|
+
tokenRepo.getRefreshToken(); // Get refresh token
|
|
210
211
|
tokenRepo.saveTokens(tokens, rememberMe);
|
|
211
212
|
tokenRepo.clearTokens();
|
|
212
213
|
|
|
213
214
|
// Validation
|
|
214
|
-
tokenRepo.isAuthenticated();
|
|
215
|
-
tokenRepo.needsRefresh();
|
|
215
|
+
tokenRepo.isAuthenticated(); // Check if token is valid
|
|
216
|
+
tokenRepo.needsRefresh(); // Check if token needs refresh
|
|
216
217
|
|
|
217
218
|
// User data extraction from JWT
|
|
218
|
-
tokenRepo.getUserData();
|
|
219
|
-
tokenRepo.isRememberMeEnabled();
|
|
219
|
+
tokenRepo.getUserData(); // Returns UserData | null
|
|
220
|
+
tokenRepo.isRememberMeEnabled(); // Check storage location
|
|
220
221
|
```
|
|
221
222
|
|
|
222
223
|
### Interceptors
|
|
223
224
|
|
|
224
225
|
**Auth Redirect Interceptor** - Handles 401 errors:
|
|
226
|
+
|
|
225
227
|
```typescript
|
|
226
228
|
import { authRedirectInterceptor } from '@acontplus/ng-auth';
|
|
227
229
|
|
|
228
|
-
provideHttpClient(
|
|
229
|
-
withInterceptors([authRedirectInterceptor])
|
|
230
|
-
);
|
|
230
|
+
provideHttpClient(withInterceptors([authRedirectInterceptor]));
|
|
231
231
|
```
|
|
232
232
|
|
|
233
233
|
**CSRF Interceptor** - Adds CSRF tokens:
|
|
234
|
+
|
|
234
235
|
```typescript
|
|
235
236
|
import { csrfInterceptor } from '@acontplus/ng-auth';
|
|
236
237
|
|
|
237
|
-
provideHttpClient(
|
|
238
|
-
withInterceptors([csrfInterceptor])
|
|
239
|
-
);
|
|
238
|
+
provideHttpClient(withInterceptors([csrfInterceptor]));
|
|
240
239
|
```
|
|
241
240
|
|
|
242
241
|
## Login Component
|
|
@@ -269,10 +268,10 @@ export class AuthPage {}
|
|
|
269
268
|
[additionalSignupControls]="extraSignupFields"
|
|
270
269
|
[footerContent]="footer"
|
|
271
270
|
/>
|
|
272
|
-
|
|
271
|
+
|
|
273
272
|
<ng-template #footer>
|
|
274
273
|
<div class="text-center">
|
|
275
|
-
<a href="/terms">Terms</a> |
|
|
274
|
+
<a href="/terms">Terms</a> |
|
|
276
275
|
<a href="/privacy">Privacy</a>
|
|
277
276
|
</div>
|
|
278
277
|
</ng-template>
|
|
@@ -282,7 +281,7 @@ export class AuthPage {
|
|
|
282
281
|
extraSigninFields = {
|
|
283
282
|
companyId: new FormControl('', Validators.required),
|
|
284
283
|
};
|
|
285
|
-
|
|
284
|
+
|
|
286
285
|
extraSignupFields = {
|
|
287
286
|
role: new FormControl('user', Validators.required),
|
|
288
287
|
};
|
|
@@ -291,16 +290,16 @@ export class AuthPage {
|
|
|
291
290
|
|
|
292
291
|
### Component Inputs
|
|
293
292
|
|
|
294
|
-
| Input
|
|
295
|
-
|
|
296
|
-
| `title`
|
|
297
|
-
| `showRegisterButton`
|
|
298
|
-
| `showRememberMe`
|
|
299
|
-
| `additionalSigninControls` | `Record<string, AbstractControl>` | `{}`
|
|
300
|
-
| `additionalSignupControls` | `Record<string, AbstractControl>` | `{}`
|
|
301
|
-
| `additionalSigninFields`
|
|
302
|
-
| `additionalSignupFields`
|
|
303
|
-
| `footerContent`
|
|
293
|
+
| Input | Type | Default | Description |
|
|
294
|
+
| -------------------------- | --------------------------------- | --------- | ------------------------- |
|
|
295
|
+
| `title` | `string` | `'Login'` | Card title |
|
|
296
|
+
| `showRegisterButton` | `boolean` | `true` | Show register toggle |
|
|
297
|
+
| `showRememberMe` | `boolean` | `true` | Show remember me checkbox |
|
|
298
|
+
| `additionalSigninControls` | `Record<string, AbstractControl>` | `{}` | Extra login fields |
|
|
299
|
+
| `additionalSignupControls` | `Record<string, AbstractControl>` | `{}` | Extra signup fields |
|
|
300
|
+
| `additionalSigninFields` | `TemplateRef` | `null` | Custom login template |
|
|
301
|
+
| `additionalSignupFields` | `TemplateRef` | `null` | Custom signup template |
|
|
302
|
+
| `footerContent` | `TemplateRef` | `null` | Custom footer |
|
|
304
303
|
|
|
305
304
|
## Architecture
|
|
306
305
|
|
|
@@ -330,6 +329,7 @@ ng-auth/
|
|
|
330
329
|
```
|
|
331
330
|
|
|
332
331
|
**Key decisions:**
|
|
332
|
+
|
|
333
333
|
- ❌ No use cases layer (unnecessary for a library)
|
|
334
334
|
- ✅ AuthState consolidates all auth operations
|
|
335
335
|
- ✅ Signals for reactive state
|
|
@@ -353,15 +353,17 @@ import { Login, AuthState } from '@acontplus/ng-auth';
|
|
|
353
353
|
<acp-login title="Welcome to Acontplus" />
|
|
354
354
|
</div>
|
|
355
355
|
`,
|
|
356
|
-
styles: [
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
356
|
+
styles: [
|
|
357
|
+
`
|
|
358
|
+
.auth-container {
|
|
359
|
+
min-height: 100vh;
|
|
360
|
+
display: flex;
|
|
361
|
+
align-items: center;
|
|
362
|
+
justify-content: center;
|
|
363
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
364
|
+
}
|
|
365
|
+
`,
|
|
366
|
+
],
|
|
365
367
|
})
|
|
366
368
|
export class AuthPage {}
|
|
367
369
|
```
|
|
@@ -387,15 +389,15 @@ import { Router } from '@angular/router';
|
|
|
387
389
|
}
|
|
388
390
|
</div>
|
|
389
391
|
</header>
|
|
390
|
-
|
|
392
|
+
|
|
391
393
|
<main>
|
|
392
394
|
<p>Welcome back, {{ user()?.displayName }}!</p>
|
|
393
395
|
<p>Email: {{ user()?.email }}</p>
|
|
394
|
-
|
|
396
|
+
|
|
395
397
|
@if (user()?.roles?.includes('admin')) {
|
|
396
398
|
<a routerLink="/admin">Admin Panel</a>
|
|
397
399
|
}
|
|
398
|
-
|
|
400
|
+
|
|
399
401
|
@if (!emailVerified()) {
|
|
400
402
|
<div class="warning">
|
|
401
403
|
Please verify your email
|
|
@@ -409,17 +411,17 @@ import { Router } from '@angular/router';
|
|
|
409
411
|
export class Dashboard {
|
|
410
412
|
private readonly authState = inject(AuthState);
|
|
411
413
|
private readonly router = inject(Router);
|
|
412
|
-
|
|
414
|
+
|
|
413
415
|
// Reactive state
|
|
414
416
|
user = this.authState.user;
|
|
415
417
|
emailVerified = this.authState.emailVerified;
|
|
416
|
-
|
|
418
|
+
|
|
417
419
|
logout() {
|
|
418
420
|
this.authState.logout().subscribe(() => {
|
|
419
421
|
this.router.navigate(['/auth']);
|
|
420
422
|
});
|
|
421
423
|
}
|
|
422
|
-
|
|
424
|
+
|
|
423
425
|
resendVerification() {
|
|
424
426
|
const email = this.user()?.email;
|
|
425
427
|
if (email) {
|
|
@@ -434,15 +436,16 @@ export class Dashboard {
|
|
|
434
436
|
If you're upgrading from the old version:
|
|
435
437
|
|
|
436
438
|
### ❌ Before (Use Cases)
|
|
439
|
+
|
|
437
440
|
```typescript
|
|
438
441
|
import { LoginUseCase, LogoutUseCase } from '@acontplus/ng-auth';
|
|
439
442
|
|
|
440
443
|
export class MyComponent {
|
|
441
444
|
constructor(
|
|
442
445
|
private loginUseCase: LoginUseCase,
|
|
443
|
-
private logoutUseCase: LogoutUseCase
|
|
446
|
+
private logoutUseCase: LogoutUseCase,
|
|
444
447
|
) {}
|
|
445
|
-
|
|
448
|
+
|
|
446
449
|
login() {
|
|
447
450
|
this.loginUseCase.execute({ email, password }).subscribe();
|
|
448
451
|
}
|
|
@@ -450,13 +453,14 @@ export class MyComponent {
|
|
|
450
453
|
```
|
|
451
454
|
|
|
452
455
|
### ✅ After (AuthState)
|
|
456
|
+
|
|
453
457
|
```typescript
|
|
454
458
|
import { inject } from '@angular/core';
|
|
455
459
|
import { AuthState } from '@acontplus/ng-auth';
|
|
456
460
|
|
|
457
461
|
export class MyComponent {
|
|
458
462
|
private authState = inject(AuthState);
|
|
459
|
-
|
|
463
|
+
|
|
460
464
|
login() {
|
|
461
465
|
this.authState.login({ email, password }).subscribe();
|
|
462
466
|
}
|
|
@@ -566,17 +570,13 @@ import { Login } from '@acontplus/ng-auth';
|
|
|
566
570
|
selector: 'app-auth',
|
|
567
571
|
standalone: true,
|
|
568
572
|
imports: [Login],
|
|
569
|
-
template: `
|
|
570
|
-
<acp-login
|
|
571
|
-
[enableDomainDiscovery]="true"
|
|
572
|
-
[showSocialLogin]="true"
|
|
573
|
-
/>
|
|
574
|
-
`,
|
|
573
|
+
template: ` <acp-login [enableDomainDiscovery]="true" [showSocialLogin]="true" /> `,
|
|
575
574
|
})
|
|
576
575
|
export class AuthPage {}
|
|
577
576
|
```
|
|
578
577
|
|
|
579
578
|
**How it works:**
|
|
579
|
+
|
|
580
580
|
1. User types email: `user@acme.com`
|
|
581
581
|
2. Library calls `/api/auth/domain-discovery` with email
|
|
582
582
|
3. Backend responds with OAuth provider and tenant info
|
|
@@ -601,6 +601,7 @@ export const routes: Routes = [
|
|
|
601
601
|
```
|
|
602
602
|
|
|
603
603
|
Configure these redirect URIs in your OAuth provider:
|
|
604
|
+
|
|
604
605
|
- Google: `https://your-app.com/auth/callback/google`
|
|
605
606
|
- Microsoft: `https://your-app.com/auth/callback/microsoft`
|
|
606
607
|
- GitHub: `https://your-app.com/auth/callback/github`
|
|
@@ -617,13 +618,11 @@ import { AuthState } from '@acontplus/ng-auth';
|
|
|
617
618
|
selector: 'app-custom-login',
|
|
618
619
|
template: `
|
|
619
620
|
<input #emailInput (blur)="checkDomain(emailInput.value)" />
|
|
620
|
-
|
|
621
|
+
|
|
621
622
|
@if (discovery?.requiresOAuth) {
|
|
622
|
-
<button (click)="loginWithOAuth()">
|
|
623
|
-
Login with {{ discovery.provider }}
|
|
624
|
-
</button>
|
|
623
|
+
<button (click)="loginWithOAuth()">Login with {{ discovery.provider }}</button>
|
|
625
624
|
}
|
|
626
|
-
|
|
625
|
+
|
|
627
626
|
@if (discovery?.allowPasswordLogin) {
|
|
628
627
|
<input type="password" [(ngModel)]="password" />
|
|
629
628
|
<button (click)="loginWithPassword()">Login</button>
|
|
@@ -632,11 +631,11 @@ import { AuthState } from '@acontplus/ng-auth';
|
|
|
632
631
|
})
|
|
633
632
|
export class CustomLogin {
|
|
634
633
|
private authState = inject(AuthState);
|
|
635
|
-
|
|
634
|
+
|
|
636
635
|
email = '';
|
|
637
636
|
password = '';
|
|
638
637
|
discovery: DomainDiscoveryResponse | null = null;
|
|
639
|
-
|
|
638
|
+
|
|
640
639
|
checkDomain(email: string) {
|
|
641
640
|
this.email = email;
|
|
642
641
|
this.authState.discoverDomain(email).subscribe({
|
|
@@ -645,10 +644,10 @@ export class CustomLogin {
|
|
|
645
644
|
},
|
|
646
645
|
});
|
|
647
646
|
}
|
|
648
|
-
|
|
647
|
+
|
|
649
648
|
loginWithOAuth() {
|
|
650
649
|
if (!this.discovery?.provider) return;
|
|
651
|
-
|
|
650
|
+
|
|
652
651
|
// This redirects to OAuth provider
|
|
653
652
|
this.authState.startOAuthFlow({
|
|
654
653
|
provider: this.discovery.provider,
|
|
@@ -656,12 +655,14 @@ export class CustomLogin {
|
|
|
656
655
|
domain: this.discovery.domain,
|
|
657
656
|
});
|
|
658
657
|
}
|
|
659
|
-
|
|
658
|
+
|
|
660
659
|
loginWithPassword() {
|
|
661
|
-
this.authState
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
660
|
+
this.authState
|
|
661
|
+
.login({
|
|
662
|
+
email: this.email,
|
|
663
|
+
password: this.password,
|
|
664
|
+
})
|
|
665
|
+
.subscribe();
|
|
665
666
|
}
|
|
666
667
|
}
|
|
667
668
|
```
|
|
@@ -682,18 +683,14 @@ import { AuthState } from '@acontplus/ng-auth';
|
|
|
682
683
|
export class MyOAuthCallback implements OnInit {
|
|
683
684
|
private authState = inject(AuthState);
|
|
684
685
|
private route = inject(ActivatedRoute);
|
|
685
|
-
|
|
686
|
+
|
|
686
687
|
ngOnInit() {
|
|
687
688
|
const provider = this.route.snapshot.paramMap.get('provider');
|
|
688
689
|
const code = this.route.snapshot.queryParamMap.get('code');
|
|
689
690
|
const state = this.route.snapshot.queryParamMap.get('state');
|
|
690
|
-
|
|
691
|
+
|
|
691
692
|
if (provider && code && state) {
|
|
692
|
-
this.authState.handleOAuthCallback(
|
|
693
|
-
provider as SocialProvider,
|
|
694
|
-
code,
|
|
695
|
-
state
|
|
696
|
-
).subscribe({
|
|
693
|
+
this.authState.handleOAuthCallback(provider as SocialProvider, code, state).subscribe({
|
|
697
694
|
next: () => {
|
|
698
695
|
// Success - AuthState redirects automatically
|
|
699
696
|
},
|
|
@@ -715,10 +712,10 @@ Here's a Node.js/Express example for Google Workspace multi-tenant:
|
|
|
715
712
|
app.post('/api/auth/domain-discovery', async (req, res) => {
|
|
716
713
|
const { email } = req.body;
|
|
717
714
|
const domain = email.split('@')[1];
|
|
718
|
-
|
|
715
|
+
|
|
719
716
|
// Check if domain is configured for OAuth
|
|
720
717
|
const tenant = await db.tenants.findOne({ domain });
|
|
721
|
-
|
|
718
|
+
|
|
722
719
|
if (tenant?.oauthProvider === 'google') {
|
|
723
720
|
return res.json({
|
|
724
721
|
provider: 'google',
|
|
@@ -728,7 +725,7 @@ app.post('/api/auth/domain-discovery', async (req, res) => {
|
|
|
728
725
|
allowPasswordLogin: tenant.allowPasswordFallback,
|
|
729
726
|
});
|
|
730
727
|
}
|
|
731
|
-
|
|
728
|
+
|
|
732
729
|
// Default to password login
|
|
733
730
|
res.json({
|
|
734
731
|
requiresOAuth: false,
|
|
@@ -739,25 +736,25 @@ app.post('/api/auth/domain-discovery', async (req, res) => {
|
|
|
739
736
|
// Get OAuth URL
|
|
740
737
|
app.get('/api/auth/social/google/url', async (req, res) => {
|
|
741
738
|
const { tenantId, domain } = req.query;
|
|
742
|
-
|
|
739
|
+
|
|
743
740
|
const tenant = await db.tenants.findOne({ id: tenantId });
|
|
744
741
|
const state = crypto.randomBytes(32).toString('hex');
|
|
745
|
-
|
|
742
|
+
|
|
746
743
|
// Store state for verification
|
|
747
744
|
await redis.set(`oauth:state:${state}`, tenantId, 'EX', 600);
|
|
748
|
-
|
|
745
|
+
|
|
749
746
|
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
750
747
|
authUrl.searchParams.set('client_id', tenant.googleClientId);
|
|
751
748
|
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/auth/callback/google');
|
|
752
749
|
authUrl.searchParams.set('response_type', 'code');
|
|
753
750
|
authUrl.searchParams.set('scope', 'openid email profile');
|
|
754
751
|
authUrl.searchParams.set('state', state);
|
|
755
|
-
|
|
752
|
+
|
|
756
753
|
// Tenant hint for Google Workspace
|
|
757
754
|
if (domain) {
|
|
758
755
|
authUrl.searchParams.set('hd', domain);
|
|
759
756
|
}
|
|
760
|
-
|
|
757
|
+
|
|
761
758
|
res.json({
|
|
762
759
|
url: authUrl.toString(),
|
|
763
760
|
state,
|
|
@@ -767,15 +764,15 @@ app.get('/api/auth/social/google/url', async (req, res) => {
|
|
|
767
764
|
// OAuth callback
|
|
768
765
|
app.post('/api/auth/social/google/callback', async (req, res) => {
|
|
769
766
|
const { code, state, tenantId } = req.body;
|
|
770
|
-
|
|
767
|
+
|
|
771
768
|
// Verify state
|
|
772
769
|
const storedTenantId = await redis.get(`oauth:state:${state}`);
|
|
773
770
|
if (!storedTenantId) {
|
|
774
771
|
return res.status(400).json({ error: 'Invalid state' });
|
|
775
772
|
}
|
|
776
|
-
|
|
773
|
+
|
|
777
774
|
const tenant = await db.tenants.findOne({ id: tenantId });
|
|
778
|
-
|
|
775
|
+
|
|
779
776
|
// Exchange code for tokens
|
|
780
777
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
781
778
|
method: 'POST',
|
|
@@ -788,20 +785,20 @@ app.post('/api/auth/social/google/callback', async (req, res) => {
|
|
|
788
785
|
redirect_uri: 'https://your-app.com/auth/callback/google',
|
|
789
786
|
}),
|
|
790
787
|
});
|
|
791
|
-
|
|
788
|
+
|
|
792
789
|
const tokens = await tokenResponse.json();
|
|
793
|
-
|
|
790
|
+
|
|
794
791
|
// Get user info
|
|
795
792
|
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
796
793
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
797
|
-
}).then(r => r.json());
|
|
798
|
-
|
|
794
|
+
}).then((r) => r.json());
|
|
795
|
+
|
|
799
796
|
// Verify domain matches tenant
|
|
800
797
|
const userDomain = userInfo.email.split('@')[1];
|
|
801
798
|
if (tenant.domain !== userDomain) {
|
|
802
799
|
return res.status(403).json({ error: 'Domain mismatch' });
|
|
803
800
|
}
|
|
804
|
-
|
|
801
|
+
|
|
805
802
|
// Create or update user
|
|
806
803
|
const user = await db.users.upsert({
|
|
807
804
|
email: userInfo.email,
|
|
@@ -809,10 +806,10 @@ app.post('/api/auth/social/google/callback', async (req, res) => {
|
|
|
809
806
|
tenantId: tenant.id,
|
|
810
807
|
emailVerified: userInfo.verified_email,
|
|
811
808
|
});
|
|
812
|
-
|
|
809
|
+
|
|
813
810
|
// Generate JWT
|
|
814
811
|
const jwt = generateJWT(user);
|
|
815
|
-
|
|
812
|
+
|
|
816
813
|
res.json({
|
|
817
814
|
token: jwt,
|
|
818
815
|
refreshToken: generateRefreshToken(user),
|
|
@@ -826,23 +823,23 @@ app.post('/api/auth/social/google/callback', async (req, res) => {
|
|
|
826
823
|
// Tenant configuration for admin panel
|
|
827
824
|
interface TenantConfig {
|
|
828
825
|
tenantId: string;
|
|
829
|
-
domain: string;
|
|
830
|
-
displayName: string;
|
|
826
|
+
domain: string; // e.g., "acme.com"
|
|
827
|
+
displayName: string; // e.g., "Acme Corporation"
|
|
831
828
|
provider: 'google' | 'microsoft';
|
|
832
|
-
|
|
829
|
+
|
|
833
830
|
// OAuth credentials
|
|
834
831
|
clientId: string;
|
|
835
|
-
clientSecret: string;
|
|
836
|
-
issuer?: string;
|
|
837
|
-
|
|
832
|
+
clientSecret: string; // Stored securely on backend
|
|
833
|
+
issuer?: string; // For OIDC providers
|
|
834
|
+
|
|
838
835
|
// Settings
|
|
839
|
-
allowedDomains?: string[];
|
|
836
|
+
allowedDomains?: string[]; // Additional domains
|
|
840
837
|
allowPasswordLogin: boolean;
|
|
841
|
-
autoProvision: boolean;
|
|
842
|
-
|
|
838
|
+
autoProvision: boolean; // Auto-create users on first login
|
|
839
|
+
|
|
843
840
|
customParameters?: {
|
|
844
|
-
hd?: string;
|
|
845
|
-
tenant?: string;
|
|
841
|
+
hd?: string; // Google Workspace domain hint
|
|
842
|
+
tenant?: string; // Azure AD tenant ID
|
|
846
843
|
};
|
|
847
844
|
}
|
|
848
845
|
```
|
|
@@ -850,6 +847,7 @@ interface TenantConfig {
|
|
|
850
847
|
### Testing OAuth Locally
|
|
851
848
|
|
|
852
849
|
For local development, use these redirect URIs:
|
|
850
|
+
|
|
853
851
|
- `http://localhost:4200/auth/callback/google`
|
|
854
852
|
- `http://localhost:4200/auth/callback/microsoft`
|
|
855
853
|
|
|
@@ -870,22 +868,22 @@ Configure your OAuth apps with these URLs in development mode.
|
|
|
870
868
|
// Tenants table
|
|
871
869
|
interface Tenant {
|
|
872
870
|
id: string;
|
|
873
|
-
domain: string;
|
|
874
|
-
displayName: string;
|
|
871
|
+
domain: string; // "acme.com"
|
|
872
|
+
displayName: string; // "Acme Corporation"
|
|
875
873
|
oauthProvider: 'google' | 'microsoft' | 'apple' | null;
|
|
876
|
-
|
|
874
|
+
|
|
877
875
|
// OAuth credentials (encrypted at rest!)
|
|
878
876
|
googleClientId?: string;
|
|
879
877
|
googleClientSecret?: string;
|
|
880
878
|
microsoftClientId?: string;
|
|
881
879
|
microsoftClientSecret?: string;
|
|
882
|
-
azureTenantId?: string;
|
|
883
|
-
|
|
880
|
+
azureTenantId?: string; // For Azure AD
|
|
881
|
+
|
|
884
882
|
// Settings
|
|
885
883
|
allowPasswordLogin: boolean;
|
|
886
884
|
autoProvisionUsers: boolean; // Auto-create users on first OAuth login
|
|
887
885
|
requireEmailVerification: boolean;
|
|
888
|
-
|
|
886
|
+
|
|
889
887
|
createdAt: Date;
|
|
890
888
|
updatedAt: Date;
|
|
891
889
|
}
|
|
@@ -895,15 +893,15 @@ interface User {
|
|
|
895
893
|
id: string;
|
|
896
894
|
email: string;
|
|
897
895
|
displayName: string;
|
|
898
|
-
tenantId?: string;
|
|
896
|
+
tenantId?: string; // Reference to tenant
|
|
899
897
|
emailVerified: boolean;
|
|
900
|
-
|
|
898
|
+
|
|
901
899
|
// Track which auth method they used
|
|
902
900
|
authProvider: 'password' | 'google' | 'microsoft' | 'github' | 'apple';
|
|
903
|
-
|
|
901
|
+
|
|
904
902
|
// OAuth user ID from provider
|
|
905
903
|
oauthProviderId?: string;
|
|
906
|
-
|
|
904
|
+
|
|
907
905
|
createdAt: Date;
|
|
908
906
|
lastLoginAt: Date;
|
|
909
907
|
}
|
|
@@ -928,6 +926,7 @@ interface User {
|
|
|
928
926
|
- Example: `authUrl.searchParams.set('hd', 'acme.com')`
|
|
929
927
|
|
|
930
928
|
**Scopes needed:**
|
|
929
|
+
|
|
931
930
|
- `openid` - Basic authentication
|
|
932
931
|
- `email` - User's email address
|
|
933
932
|
- `profile` - User's name and photo
|
|
@@ -954,6 +953,7 @@ interface User {
|
|
|
954
953
|
11. Add: `openid`, `email`, `profile`
|
|
955
954
|
|
|
956
955
|
**For multi-tenant:**
|
|
956
|
+
|
|
957
957
|
- Use tenant-specific endpoint: `https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/authorize`
|
|
958
958
|
- Or use common endpoint: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
|
|
959
959
|
|
|
@@ -969,6 +969,7 @@ interface User {
|
|
|
969
969
|
5. Copy **Client ID** and generate **Client Secret**
|
|
970
970
|
|
|
971
971
|
**Scopes needed:**
|
|
972
|
+
|
|
972
973
|
- `user:email` - Access user's email address
|
|
973
974
|
- `read:user` - Read user profile information
|
|
974
975
|
|
|
@@ -982,6 +983,7 @@ interface User {
|
|
|
982
983
|
**Cause**: The redirect URI in your OAuth request doesn't match what's configured in the provider's settings.
|
|
983
984
|
|
|
984
985
|
**Solutions**:
|
|
986
|
+
|
|
985
987
|
1. Ensure exact match including protocol (`http://` vs `https://`), port, and path
|
|
986
988
|
2. No trailing slash unless configured that way
|
|
987
989
|
3. Check for typos in domain name
|
|
@@ -995,6 +997,7 @@ interface User {
|
|
|
995
997
|
**Cause**: OAuth state token expired or doesn't match.
|
|
996
998
|
|
|
997
999
|
**Solutions**:
|
|
1000
|
+
|
|
998
1001
|
1. Increase Redis/storage TTL for state tokens (recommended: 600 seconds)
|
|
999
1002
|
2. User may have taken too long to complete OAuth flow
|
|
1000
1003
|
3. Ensure state is properly stored before redirect
|
|
@@ -1008,6 +1011,7 @@ interface User {
|
|
|
1008
1011
|
**Cause**: User authenticated with an account from a different organization.
|
|
1009
1012
|
|
|
1010
1013
|
**Solutions**:
|
|
1014
|
+
|
|
1011
1015
|
1. For Google: Use `hd` parameter to force specific domain
|
|
1012
1016
|
2. Show clear error message explaining which domain is expected
|
|
1013
1017
|
3. Verify domain matching logic in backend before issuing JWT
|
|
@@ -1021,6 +1025,7 @@ interface User {
|
|
|
1021
1025
|
**Cause**: Frontend or backend error during token exchange.
|
|
1022
1026
|
|
|
1023
1027
|
**Solutions**:
|
|
1028
|
+
|
|
1024
1029
|
1. Check browser console for JavaScript errors
|
|
1025
1030
|
2. Verify backend endpoint is responding
|
|
1026
1031
|
3. Check CORS configuration on backend
|
|
@@ -1036,29 +1041,29 @@ interface User {
|
|
|
1036
1041
|
describe('OAuth Flow', () => {
|
|
1037
1042
|
let authState: AuthState;
|
|
1038
1043
|
let httpMock: HttpTestingController;
|
|
1039
|
-
|
|
1044
|
+
|
|
1040
1045
|
beforeEach(() => {
|
|
1041
1046
|
TestBed.configureTestingModule({
|
|
1042
1047
|
imports: [HttpClientTestingModule],
|
|
1043
1048
|
providers: [AuthState, ...authProviders],
|
|
1044
1049
|
});
|
|
1045
|
-
|
|
1050
|
+
|
|
1046
1051
|
authState = TestBed.inject(AuthState);
|
|
1047
1052
|
httpMock = TestBed.inject(HttpTestingController);
|
|
1048
1053
|
});
|
|
1049
|
-
|
|
1054
|
+
|
|
1050
1055
|
it('should discover Google Workspace tenant', (done) => {
|
|
1051
|
-
authState.discoverDomain('user@acme.com').subscribe(result => {
|
|
1056
|
+
authState.discoverDomain('user@acme.com').subscribe((result) => {
|
|
1052
1057
|
expect(result.provider).toBe('google');
|
|
1053
1058
|
expect(result.tenantId).toBe('acme-123');
|
|
1054
1059
|
expect(result.requiresOAuth).toBe(true);
|
|
1055
1060
|
done();
|
|
1056
1061
|
});
|
|
1057
|
-
|
|
1062
|
+
|
|
1058
1063
|
const req = httpMock.expectOne('/api/auth/domain-discovery');
|
|
1059
1064
|
expect(req.request.method).toBe('POST');
|
|
1060
1065
|
expect(req.request.body).toEqual({ email: 'user@acme.com' });
|
|
1061
|
-
|
|
1066
|
+
|
|
1062
1067
|
req.flush({
|
|
1063
1068
|
provider: 'google',
|
|
1064
1069
|
tenantId: 'acme-123',
|
|
@@ -1066,25 +1071,23 @@ describe('OAuth Flow', () => {
|
|
|
1066
1071
|
allowPasswordLogin: false,
|
|
1067
1072
|
});
|
|
1068
1073
|
});
|
|
1069
|
-
|
|
1074
|
+
|
|
1070
1075
|
it('should start OAuth flow with tenant context', () => {
|
|
1071
1076
|
spyOn(window.location, 'href', 'set');
|
|
1072
|
-
|
|
1077
|
+
|
|
1073
1078
|
authState.startOAuthFlow({
|
|
1074
1079
|
provider: 'google',
|
|
1075
1080
|
tenantId: 'acme-123',
|
|
1076
1081
|
domain: 'acme.com',
|
|
1077
1082
|
});
|
|
1078
|
-
|
|
1079
|
-
const req = httpMock.expectOne(
|
|
1080
|
-
|
|
1081
|
-
);
|
|
1082
|
-
|
|
1083
|
+
|
|
1084
|
+
const req = httpMock.expectOne((req) => req.url.includes('/api/auth/social/google/url'));
|
|
1085
|
+
|
|
1083
1086
|
req.flush({
|
|
1084
1087
|
url: 'https://accounts.google.com/o/oauth2/v2/auth?...',
|
|
1085
1088
|
state: 'test-state-token',
|
|
1086
1089
|
});
|
|
1087
|
-
|
|
1090
|
+
|
|
1088
1091
|
// Verify state stored in session storage
|
|
1089
1092
|
expect(sessionStorage.getItem('oauth_state')).toBe('test-state-token');
|
|
1090
1093
|
expect(sessionStorage.getItem('oauth_tenant_id')).toBe('acme-123');
|