diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1bcdb07 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(ng generate:*)", + "Bash(git add:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/angular.json b/angular.json index 51c7d61..7f13c05 100644 --- a/angular.json +++ b/angular.json @@ -460,6 +460,36 @@ } } } + }, + "auth-client": { + "projectType": "library", + "root": "projects/auth-client", + "sourceRoot": "projects/auth-client/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "configurations": { + "production": { + "tsConfig": "projects/auth-client/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/auth-client/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:karma", + "options": { + "tsConfig": "projects/auth-client/tsconfig.spec.json", + "polyfills": [ + "zone.js", + "zone.js/testing" + ] + } + } + } } } } diff --git a/package.json b/package.json index a42f0c5..ba2b0f4 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@angular-devkit/build-angular": "^19.2.15", + "@angular/build": "^20.2.0", "@angular/cli": "^19.2.15", "@angular/compiler-cli": "^19.2.0", "@types/jasmine": "~5.1.0", @@ -42,4 +43,4 @@ "ng-packagr": "^19.2.0", "typescript": "~5.7.2" } -} +} \ No newline at end of file diff --git a/projects/auth-client/README.md b/projects/auth-client/README.md new file mode 100644 index 0000000..2463c58 --- /dev/null +++ b/projects/auth-client/README.md @@ -0,0 +1,468 @@ +# Auth Client Library + +Angular client library for integrating with the Elixir-based auth service. Provides authentication, authorization, and user management capabilities. + +## Features + +- **Authentication**: Login, register, logout with JWT tokens +- **Token Management**: Automatic token refresh and storage +- **OAuth Integration**: Social authentication with multiple providers +- **Two-Factor Authentication**: TOTP and backup codes support +- **Route Guards**: Protect routes based on authentication and scopes +- **HTTP Interceptor**: Automatic token injection and refresh +- **TypeScript Support**: Fully typed interfaces and models + +## Installation + +```bash +npm install auth-client +``` + +## Setup + +### 1. Import the HTTP Client Module + +```typescript +import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; + +@NgModule({ + imports: [ + HttpClientModule, + // ... other imports + ], +}) +export class AppModule {} +``` + +### 2. Configure the Auth Service + +```typescript +import { AuthService } from 'auth-client'; + +export class AppComponent { + constructor(private authService: AuthService) { + // Configure the base URL for your auth service + this.authService.configure('http://localhost:4000'); + } +} +``` + +### 3. Add HTTP Interceptor (Optional) + +```typescript +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { AuthInterceptor } from 'auth-client'; + +@NgModule({ + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } + ] +}) +export class AppModule {} +``` + +## Usage + +### Authentication + +#### Login + +```typescript +import { AuthService } from 'auth-client'; + +constructor(private authService: AuthService) {} + +login() { + this.authService.login({ + email: 'user@example.com', + password: 'password123' + }).subscribe({ + next: (response) => { + console.log('Login successful:', response.user); + }, + error: (error) => { + console.error('Login failed:', error); + } + }); +} +``` + +#### Register + +```typescript +register() { + this.authService.register({ + email: 'user@example.com', + password: 'password123', + first_name: 'John', + last_name: 'Doe' + }).subscribe({ + next: (response) => { + console.log('Registration successful:', response.user); + }, + error: (error) => { + console.error('Registration failed:', error); + } + }); +} +``` + +#### Logout + +```typescript +logout() { + this.authService.logout().subscribe({ + next: () => { + console.log('Logout successful'); + }, + error: (error) => { + console.error('Logout failed:', error); + } + }); +} +``` + +### Authentication State + +```typescript +import { AuthService } from 'auth-client'; + +constructor(private authService: AuthService) { + // Subscribe to authentication state + this.authService.isAuthenticated$.subscribe(isAuth => { + console.log('Is authenticated:', isAuth); + }); + + // Subscribe to current user + this.authService.currentUser$.subscribe(user => { + console.log('Current user:', user); + }); + + // Check if authenticated synchronously + const isAuth = this.authService.isAuthenticated(); +} +``` + +### Route Guards + +#### Protect Authenticated Routes + +```typescript +import { AuthGuard } from 'auth-client'; + +const routes: Routes = [ + { + path: 'dashboard', + component: DashboardComponent, + canActivate: [AuthGuard] + }, + { + path: 'admin', + component: AdminComponent, + canActivate: [AuthGuard], + data: { + requiredScopes: ['admin'], // Require admin scope + requireAllScopes: true, // Must have all scopes (default: false) + unauthorizedRedirect: '/access-denied' + } + } +]; +``` + +#### Protect Guest Routes + +```typescript +import { GuestGuard } from 'auth-client'; + +const routes: Routes = [ + { + path: 'login', + component: LoginComponent, + canActivate: [GuestGuard], // Redirect to home if already authenticated + data: { + authenticatedRedirect: '/dashboard' + } + } +]; +``` + +### OAuth Integration + +#### Get Available Providers + +```typescript +import { OAuthService } from 'auth-client'; + +constructor(private oauthService: OAuthService) {} + +getProviders() { + this.oauthService.getProviders().subscribe(providers => { + console.log('Available providers:', providers); + }); +} +``` + +#### Initiate OAuth Flow + +```typescript +// Redirect flow +loginWithGoogle() { + this.oauthService.initiateOAuthFlow('google', 'http://localhost:4200/oauth/callback'); +} + +// Popup flow +async loginWithGooglePopup() { + try { + const result = await this.oauthService.initiateOAuthPopup('google'); + console.log('OAuth successful:', result); + } catch (error) { + console.error('OAuth failed:', error); + } +} +``` + +#### Handle OAuth Callback + +```typescript +// In your callback component +ngOnInit() { + this.oauthService.completeOAuthFlow('google').subscribe({ + next: (result) => { + console.log('OAuth login successful:', result); + this.router.navigate(['/dashboard']); + }, + error: (error) => { + console.error('OAuth login failed:', error); + this.router.navigate(['/login']); + } + }); +} +``` + +### Two-Factor Authentication + +#### Setup 2FA + +```typescript +setup2FA() { + this.authService.setup2FA().subscribe({ + next: (response) => { + // Display QR code and backup codes + console.log('QR Code:', response.qr_code); + console.log('Backup codes:', response.backup_codes); + }, + error: (error) => { + console.error('2FA setup failed:', error); + } + }); +} +``` + +#### Verify 2FA Setup + +```typescript +verify2FA(token: string) { + this.authService.verify2FASetup({ token }).subscribe({ + next: () => { + console.log('2FA enabled successfully'); + }, + error: (error) => { + console.error('2FA verification failed:', error); + } + }); +} +``` + +### User Management + +#### Update Profile + +```typescript +updateProfile() { + this.authService.updateProfile({ + first_name: 'Jane', + last_name: 'Smith' + }).subscribe({ + next: (user) => { + console.log('Profile updated:', user); + }, + error: (error) => { + console.error('Profile update failed:', error); + } + }); +} +``` + +#### Change Password + +```typescript +changePassword() { + this.authService.changePassword({ + current_password: 'oldpassword', + new_password: 'newpassword', + new_password_confirmation: 'newpassword' + }).subscribe({ + next: () => { + console.log('Password changed successfully'); + }, + error: (error) => { + console.error('Password change failed:', error); + } + }); +} +``` + +### Token Management + +#### Manual Token Operations + +```typescript +import { TokenService } from 'auth-client'; + +constructor(private tokenService: TokenService) {} + +checkToken() { + // Check if token exists and is valid + const isValid = this.tokenService.isTokenValid(); + + // Get user information from token + const userId = this.tokenService.getUserId(); + const email = this.tokenService.getUserEmail(); + const scopes = this.tokenService.getUserScopes(); + + // Check scopes + const hasAdminScope = this.tokenService.hasScope('admin'); + const hasAnyScope = this.tokenService.hasAnyScope(['read', 'write']); + const hasAllScopes = this.tokenService.hasAllScopes(['read', 'write']); +} +``` + +## API Reference + +### Models + +All TypeScript interfaces are available for import: + +```typescript +import { + User, + LoginRequest, + LoginResponse, + RegisterRequest, + TokenPair, + ApiError, + // ... and more +} from 'auth-client'; +``` + +### Services + +- **AuthService**: Main authentication service +- **TokenService**: Token management and validation +- **OAuthService**: OAuth provider integration +- **AuthHttpService**: Low-level HTTP client + +### Guards + +- **AuthGuard**: Protect authenticated routes +- **GuestGuard**: Protect guest-only routes + +### Interceptors + +- **AuthInterceptor**: Automatic token injection and refresh + +## Configuration + +### Environment Variables + +You can configure the auth service URL through your environment: + +```typescript +// environment.ts +export const environment = { + authServiceUrl: 'http://localhost:4000' +}; + +// app.component.ts +constructor(private authService: AuthService) { + this.authService.configure(environment.authServiceUrl); +} +``` + +### Token Storage + +By default, tokens are stored in localStorage. The library handles: + +- Automatic token refresh before expiration +- Cross-tab synchronization +- Token validation and cleanup + +## Error Handling + +All services return Observable streams with proper error handling: + +```typescript +this.authService.login(credentials).subscribe({ + next: (response) => { + // Handle success + }, + error: (apiError: ApiError) => { + console.error('Error:', apiError.error); + + // Handle specific errors + if (apiError.requires_2fa) { + // Redirect to 2FA input + } + + if (apiError.details) { + // Handle validation errors + console.error('Validation errors:', apiError.details); + } + } +}); +``` + +## Building + +To build the library, run: + +```bash +ng build auth-client +``` + +This command will compile your project, and the build artifacts will be placed in the `dist/` directory. + +## Publishing the Library + +Once the project is built, you can publish your library by following these steps: + +1. Navigate to the `dist` directory: + ```bash + cd dist/auth-client + ``` + +2. Run the `npm publish` command to publish your library to the npm registry: + ```bash + npm publish + ``` + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test auth-client +``` + +## Contributing + +This library is designed to work with the Elixir auth service. Please ensure API compatibility when making changes. + +## License + +MIT License diff --git a/projects/auth-client/USAGE_GUIDE.md b/projects/auth-client/USAGE_GUIDE.md new file mode 100644 index 0000000..5ae19ff --- /dev/null +++ b/projects/auth-client/USAGE_GUIDE.md @@ -0,0 +1,1076 @@ +# Auth Client Library Usage Guide + +## Quick Start + +### 1. Installation & Setup + +```bash +# Install the library (after building it locally) +npm install auth-client + +# Or if using locally +ng build auth-client +# Then reference from dist/auth-client +``` + +### 2. App Module Configuration + +```typescript +// app.module.ts +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { AuthInterceptor } from 'auth-client'; +import { AppComponent } from './app.component'; + +@NgModule({ + declarations: [AppComponent], + imports: [ + BrowserModule, + HttpClientModule, // Required for HTTP calls + ReactiveFormsModule // For forms + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } + ], + bootstrap: [AppComponent] +}) +export class AppModule { } +``` + +### 3. Environment Configuration + +```typescript +// src/environments/environment.ts +export const environment = { + production: false, + authServiceUrl: 'http://localhost:4000' // Your Elixir auth service URL +}; + +// src/environments/environment.prod.ts +export const environment = { + production: true, + authServiceUrl: 'https://your-auth-service.com' +}; +``` + +### 4. App Component Setup + +```typescript +// app.component.ts +import { Component, OnInit } from '@angular/core'; +import { AuthService } from 'auth-client'; +import { environment } from '../environments/environment'; + +@Component({ + selector: 'app-root', + template: ` +
+ + +
+ ` +}) +export class AppComponent implements OnInit { + isAuthenticated$ = this.authService.isAuthenticated$; + currentUser$ = this.authService.currentUser$; + + constructor(private authService: AuthService) {} + + ngOnInit() { + // Configure the auth service with your API URL + this.authService.configure(environment.authServiceUrl); + } + + logout() { + this.authService.logout().subscribe(); + } +} +``` + +## Authentication Components + +### Login Component + +```typescript +// login.component.ts +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService, ApiError } from 'auth-client'; + +@Component({ + selector: 'app-login', + template: ` +
+

Login

+ +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ {{ errorMessage }} +
+ + + + +
+ + +
+ +

+ Don't have an account? + Register here +

+
+ ` +}) +export class LoginComponent { + loginForm: FormGroup; + errorMessage = ''; + requires2FA = false; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.loginForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(6)]], + totp_code: [''] + }); + } + + onSubmit() { + if (this.loginForm.valid) { + this.isLoading = true; + this.errorMessage = ''; + + this.authService.login(this.loginForm.value).subscribe({ + next: (response) => { + console.log('Login successful:', response.user); + this.router.navigate(['/dashboard']); + }, + error: (error: ApiError) => { + this.isLoading = false; + + if (error.requires_2fa) { + this.requires2FA = true; + this.errorMessage = 'Please enter your 2FA code'; + } else { + this.errorMessage = error.error; + } + }, + complete: () => { + this.isLoading = false; + } + }); + } + } + + loginWithGoogle() { + // Redirect method + this.authService.initiateOAuthFlow('google', window.location.origin + '/oauth/callback'); + } + + loginWithGitHub() { + // Popup method + this.authService.initiateOAuthPopup('github').then( + result => { + console.log('OAuth successful:', result); + this.router.navigate(['/dashboard']); + }, + error => { + this.errorMessage = 'OAuth login failed: ' + error.message; + } + ); + } +} +``` + +### Register Component + +```typescript +// register.component.ts +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService, ApiError } from 'auth-client'; + +@Component({ + selector: 'app-register', + template: ` +
+

Register

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {{ errorMessage }} +
+ +
+ +
+ + + +

+ Already have an account? + Login here +

+
+ ` +}) +export class RegisterComponent { + registerForm: FormGroup; + errorMessage = ''; + validationErrors: any = null; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.registerForm = this.fb.group({ + first_name: [''], + last_name: [''], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + password_confirmation: ['', [Validators.required]] + }, { + validators: this.passwordMatchValidator + }); + } + + passwordMatchValidator(form: FormGroup) { + const password = form.get('password'); + const confirmPassword = form.get('password_confirmation'); + + if (password && confirmPassword && password.value !== confirmPassword.value) { + confirmPassword.setErrors({ passwordMismatch: true }); + } + return null; + } + + onSubmit() { + if (this.registerForm.valid) { + this.isLoading = true; + this.errorMessage = ''; + this.validationErrors = null; + + this.authService.register(this.registerForm.value).subscribe({ + next: (response) => { + console.log('Registration successful:', response.user); + this.router.navigate(['/dashboard']); + }, + error: (error: ApiError) => { + this.isLoading = false; + + if (error.details) { + this.validationErrors = error.details; + } else { + this.errorMessage = error.error; + } + }, + complete: () => { + this.isLoading = false; + } + }); + } + } + + getValidationErrorMessages(): string[] { + if (!this.validationErrors) return []; + + const messages: string[] = []; + Object.keys(this.validationErrors).forEach(field => { + const errors = this.validationErrors[field]; + errors.forEach((error: string) => { + messages.push(`${field}: ${error}`); + }); + }); + return messages; + } +} +``` + +## Route Protection + +### Setting Up Protected Routes + +```typescript +// app-routing.module.ts +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AuthGuard, GuestGuard } from 'auth-client'; + +import { LoginComponent } from './login/login.component'; +import { RegisterComponent } from './register/register.component'; +import { DashboardComponent } from './dashboard/dashboard.component'; +import { AdminComponent } from './admin/admin.component'; +import { ProfileComponent } from './profile/profile.component'; + +const routes: Routes = [ + // Guest routes (redirect to dashboard if authenticated) + { + path: 'login', + component: LoginComponent, + canActivate: [GuestGuard], + data: { authenticatedRedirect: '/dashboard' } + }, + { + path: 'register', + component: RegisterComponent, + canActivate: [GuestGuard] + }, + + // Protected routes (require authentication) + { + path: 'dashboard', + component: DashboardComponent, + canActivate: [AuthGuard] + }, + { + path: 'profile', + component: ProfileComponent, + canActivate: [AuthGuard] + }, + + // Admin routes (require authentication + admin scope) + { + path: 'admin', + component: AdminComponent, + canActivate: [AuthGuard], + data: { + requiredScopes: ['admin'], + requireAllScopes: true, + unauthorizedRedirect: '/access-denied' + } + }, + + // Multi-scope example + { + path: 'reports', + component: ReportsComponent, + canActivate: [AuthGuard], + data: { + requiredScopes: ['read:reports', 'analytics'], + requireAllScopes: false, // User needs ANY of these scopes + unauthorizedRedirect: '/dashboard' + } + }, + + { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, + { path: '**', redirectTo: '/dashboard' } +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] +}) +export class AppRoutingModule { } +``` + +## User Profile Management + +### Profile Component + +```typescript +// profile.component.ts +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { AuthService, User } from 'auth-client'; + +@Component({ + selector: 'app-profile', + template: ` +
+

User Profile

+ + +
+

Account Information

+

Email: {{ currentUser.email }}

+

Status: + + {{ currentUser.is_active ? 'Active' : 'Inactive' }} + +

+

Email Verified: + + {{ currentUser.email_verified ? 'Yes' : 'No' }} + +

+

Member Since: {{ currentUser.created_at | date }}

+
+ + +
+

Update Profile

+ +
+ + +
+ +
+ + +
+ + +
+ + +
+

Change Password

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+

Two-Factor Authentication

+
+

Status: {{ twoFactorStatus.enabled ? 'Enabled' : 'Disabled' }}

+
+

Backup codes remaining: {{ twoFactorStatus.backup_codes_remaining }}

+ +
+
+ +
+
+
+ + +
{{ successMessage }}
+
{{ errorMessage }}
+
+ ` +}) +export class ProfileComponent implements OnInit { + currentUser: User | null = null; + profileForm: FormGroup; + passwordForm: FormGroup; + twoFactorStatus: any = null; + + isUpdating = false; + isChangingPassword = false; + successMessage = ''; + errorMessage = ''; + + constructor( + private fb: FormBuilder, + private authService: AuthService + ) { + this.profileForm = this.fb.group({ + first_name: [''], + last_name: [''] + }); + + this.passwordForm = this.fb.group({ + current_password: ['', Validators.required], + new_password: ['', [Validators.required, Validators.minLength(8)]], + new_password_confirmation: ['', Validators.required] + }); + } + + ngOnInit() { + this.loadUserData(); + this.load2FAStatus(); + } + + loadUserData() { + this.authService.currentUser$.subscribe(user => { + if (user) { + this.currentUser = user; + this.profileForm.patchValue({ + first_name: user.first_name || '', + last_name: user.last_name || '' + }); + } + }); + } + + load2FAStatus() { + this.authService.get2FAStatus().subscribe({ + next: (status) => { + this.twoFactorStatus = status; + }, + error: (error) => { + console.error('Failed to load 2FA status:', error); + } + }); + } + + updateProfile() { + if (this.profileForm.valid) { + this.isUpdating = true; + this.clearMessages(); + + this.authService.updateProfile(this.profileForm.value).subscribe({ + next: (user) => { + this.successMessage = 'Profile updated successfully'; + this.currentUser = user; + }, + error: (error) => { + this.errorMessage = error.error; + }, + complete: () => { + this.isUpdating = false; + } + }); + } + } + + changePassword() { + if (this.passwordForm.valid) { + this.isChangingPassword = true; + this.clearMessages(); + + this.authService.changePassword(this.passwordForm.value).subscribe({ + next: () => { + this.successMessage = 'Password changed successfully'; + this.passwordForm.reset(); + }, + error: (error) => { + this.errorMessage = error.error; + }, + complete: () => { + this.isChangingPassword = false; + } + }); + } + } + + setup2FA() { + this.authService.setup2FA().subscribe({ + next: (response) => { + // Show QR code and backup codes in a modal or new component + console.log('2FA Setup:', response); + // You would typically show this in a modal + alert(`Scan this QR code: ${response.qr_code}\nBackup codes: ${response.backup_codes.join(', ')}`); + }, + error: (error) => { + this.errorMessage = error.error; + } + }); + } + + disable2FA() { + if (confirm('Are you sure you want to disable 2FA?')) { + this.authService.disable2FA().subscribe({ + next: () => { + this.successMessage = '2FA disabled successfully'; + this.load2FAStatus(); + }, + error: (error) => { + this.errorMessage = error.error; + } + }); + } + } + + private clearMessages() { + this.successMessage = ''; + this.errorMessage = ''; + } +} +``` + +## OAuth Integration + +### OAuth Callback Component + +```typescript +// oauth-callback.component.ts +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OAuthService } from 'auth-client'; + +@Component({ + selector: 'app-oauth-callback', + template: ` +
+

Processing OAuth Login...

+
Loading...
+
+

{{ errorMessage }}

+ Back to Login +
+
+ ` +}) +export class OAuthCallbackComponent implements OnInit { + isProcessing = true; + errorMessage = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private oauthService: OAuthService + ) {} + + ngOnInit() { + // Get provider from route params + const provider = this.route.snapshot.params['provider']; + + if (!provider) { + this.errorMessage = 'Invalid OAuth callback - no provider specified'; + this.isProcessing = false; + return; + } + + // Complete OAuth flow + this.oauthService.completeOAuthFlow(provider).subscribe({ + next: (result) => { + console.log('OAuth login successful:', result); + this.router.navigate(['/dashboard']); + }, + error: (error) => { + console.error('OAuth login failed:', error); + this.errorMessage = error.error || 'OAuth login failed'; + this.isProcessing = false; + } + }); + } +} +``` + +### OAuth Buttons Component + +```typescript +// oauth-buttons.component.ts +import { Component, OnInit } from '@angular/core'; +import { OAuthService, OAuthProvider } from 'auth-client'; + +@Component({ + selector: 'app-oauth-buttons', + template: ` +
+

Or login with:

+
+ +
+
+ ` +}) +export class OAuthButtonsComponent implements OnInit { + providers: OAuthProvider[] = []; + + constructor(private oauthService: OAuthService) {} + + ngOnInit() { + this.loadProviders(); + } + + loadProviders() { + this.oauthService.getProviders().subscribe({ + next: (providers) => { + this.providers = providers; + }, + error: (error) => { + console.error('Failed to load OAuth providers:', error); + } + }); + } + + loginWithProvider(provider: string) { + const redirectUri = `${window.location.origin}/oauth/callback/${provider}`; + this.oauthService.initiateOAuthFlow(provider, redirectUri); + } +} +``` + +## Advanced Usage + +### Checking User Permissions + +```typescript +// any.component.ts +import { Component } from '@angular/core'; +import { AuthService } from 'auth-client'; + +@Component({ + selector: 'app-example', + template: ` +
+ + + +
+ ` +}) +export class ExampleComponent { + get canEdit(): boolean { + return this.authService.hasScope('edit'); + } + + get canDelete(): boolean { + return this.authService.hasScope('delete'); + } + + get isAdmin(): boolean { + return this.authService.hasScope('admin'); + } + + constructor(private authService: AuthService) {} + + editItem() { + console.log('Editing item...'); + } + + deleteItem() { + console.log('Deleting item...'); + } + + adminAction() { + console.log('Admin action...'); + } +} +``` + +### Custom Auth State Service + +```typescript +// auth-state.service.ts +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { AuthService, User } from 'auth-client'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthStateService { + private userMenuOpenSubject = new BehaviorSubject(false); + public userMenuOpen$ = this.userMenuOpenSubject.asObservable(); + + constructor(private authService: AuthService) {} + + get isAuthenticated$(): Observable { + return this.authService.isAuthenticated$; + } + + get currentUser$(): Observable { + return this.authService.currentUser$; + } + + get userScopes(): string[] { + return this.authService.getUserScopes(); + } + + toggleUserMenu(): void { + this.userMenuOpenSubject.next(!this.userMenuOpenSubject.value); + } + + closeUserMenu(): void { + this.userMenuOpenSubject.next(false); + } + + hasPermission(permission: string): boolean { + return this.authService.hasScope(permission); + } + + hasAnyPermission(permissions: string[]): boolean { + return this.authService.hasAnyScope(permissions); + } + + hasAllPermissions(permissions: string[]): boolean { + return this.authService.hasAllScopes(permissions); + } +} +``` + +## Error Handling Examples + +### Global Error Handler + +```typescript +// global-error.handler.ts +import { Injectable, ErrorHandler } from '@angular/core'; +import { AuthService } from 'auth-client'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + constructor(private authService: AuthService) {} + + handleError(error: any): void { + console.error('Global error caught:', error); + + // Handle auth-related errors + if (error?.status === 401) { + this.authService.logoutSilently(); + // Optionally redirect to login + window.location.href = '/login'; + } + + // Handle other errors... + } +} +``` + +### HTTP Error Interceptor + +```typescript +// error.interceptor.ts +import { Injectable } from '@angular/core'; +import { HttpInterceptor, HttpRequest, HttpHandler, HttpErrorResponse } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { AuthService } from 'auth-client'; + +@Injectable() +export class ErrorInterceptor implements HttpInterceptor { + constructor(private authService: AuthService) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable { + return next.handle(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + // Token expired or invalid + this.authService.logoutSilently(); + } else if (error.status === 403) { + // Insufficient permissions + console.warn('Access denied:', error.error); + } else if (error.status >= 500) { + // Server error + console.error('Server error:', error.error); + } + + return throwError(() => error); + }) + ); + } +} +``` + +## Testing Examples + +### Service Testing + +```typescript +// auth.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { AuthService, AuthHttpService, TokenService } from 'auth-client'; + +describe('AuthService', () => { + let service: AuthService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [AuthService, AuthHttpService, TokenService] + }); + + service = TestBed.inject(AuthService); + httpMock = TestBed.inject(HttpTestingController); + + // Configure the service + service.configure('http://localhost:4000'); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should login successfully', () => { + const mockResponse = { + access_token: 'mock-token', + refresh_token: 'mock-refresh', + token_type: 'Bearer', + expires_in: 3600, + user: { id: '1', email: 'test@example.com' } + }; + + service.login({ email: 'test@example.com', password: 'password' }).subscribe(response => { + expect(response.user.email).toBe('test@example.com'); + }); + + const req = httpMock.expectOne('http://localhost:4000/api/v1/auth/login'); + expect(req.request.method).toBe('POST'); + req.flush(mockResponse); + }); +}); +``` + +### Component Testing + +```typescript +// login.component.spec.ts +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { LoginComponent } from './login.component'; +import { AuthService } from 'auth-client'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + let mockAuthService: jasmine.SpyObj; + let mockRouter: jasmine.SpyObj; + + beforeEach(() => { + const authSpy = jasmine.createSpyObj('AuthService', ['login']); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + TestBed.configureTestingModule({ + declarations: [LoginComponent], + imports: [ReactiveFormsModule], + providers: [ + { provide: AuthService, useValue: authSpy }, + { provide: Router, useValue: routerSpy } + ] + }); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + mockAuthService = TestBed.inject(AuthService) as jasmine.SpyObj; + mockRouter = TestBed.inject(Router) as jasmine.SpyObj; + }); + + it('should login successfully', () => { + const mockResponse = { + access_token: 'token', + refresh_token: 'refresh', + token_type: 'Bearer', + expires_in: 3600, + user: { id: '1', email: 'test@example.com' } + }; + + mockAuthService.login.and.returnValue(of(mockResponse)); + + component.loginForm.patchValue({ + email: 'test@example.com', + password: 'password' + }); + + component.onSubmit(); + + expect(mockAuthService.login).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/dashboard']); + }); + + it('should handle login error', () => { + const mockError = { error: 'Invalid credentials' }; + mockAuthService.login.and.returnValue(throwError(() => mockError)); + + component.loginForm.patchValue({ + email: 'test@example.com', + password: 'wrong-password' + }); + + component.onSubmit(); + + expect(component.errorMessage).toBe('Invalid credentials'); + }); +}); +``` + +This comprehensive usage guide covers all the major features and use cases of the auth client library, providing practical examples for implementing authentication in Angular applications. \ No newline at end of file diff --git a/projects/auth-client/ng-package.json b/projects/auth-client/ng-package.json new file mode 100644 index 0000000..26c7901 --- /dev/null +++ b/projects/auth-client/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/auth-client", + "lib": { + "entryFile": "src/public-api.ts" + } +} \ No newline at end of file diff --git a/projects/auth-client/package.json b/projects/auth-client/package.json new file mode 100644 index 0000000..5d33921 --- /dev/null +++ b/projects/auth-client/package.json @@ -0,0 +1,12 @@ +{ + "name": "auth-client", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^20.2.0", + "@angular/core": "^20.2.0" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/projects/auth-client/src/lib/auth-client.spec.ts b/projects/auth-client/src/lib/auth-client.spec.ts new file mode 100644 index 0000000..328c7e4 --- /dev/null +++ b/projects/auth-client/src/lib/auth-client.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthClient } from './auth-client'; + +describe('AuthClient', () => { + let component: AuthClient; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AuthClient] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AuthClient); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/auth-client/src/lib/auth-client.ts b/projects/auth-client/src/lib/auth-client.ts new file mode 100644 index 0000000..887b574 --- /dev/null +++ b/projects/auth-client/src/lib/auth-client.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib-auth-client', + imports: [], + template: ` +

+ auth-client works! +

+ `, + styles: `` +}) +export class AuthClient { + +} diff --git a/projects/auth-client/src/lib/guards/auth.guard.ts b/projects/auth-client/src/lib/guards/auth.guard.ts new file mode 100644 index 0000000..0a943d4 --- /dev/null +++ b/projects/auth-client/src/lib/guards/auth.guard.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, CanActivateChild, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { map, take, tap } from 'rxjs/operators'; +import { AuthService } from '../services/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthGuard implements CanActivate, CanActivateChild { + constructor( + private authService: AuthService, + private router: Router + ) {} + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return this.checkAuth(route, state.url); + } + + canActivateChild( + childRoute: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return this.canActivate(childRoute, state); + } + + private checkAuth(route: ActivatedRouteSnapshot, redirectUrl: string): Observable { + return this.authService.isAuthenticated$.pipe( + take(1), + map(isAuthenticated => { + if (isAuthenticated) { + // Check for required scopes if specified + const requiredScopes = route.data?.['requiredScopes'] as string[]; + if (requiredScopes && requiredScopes.length > 0) { + const hasScopes = route.data?.['requireAllScopes'] + ? this.authService.hasAllScopes(requiredScopes) + : this.authService.hasAnyScope(requiredScopes); + + if (!hasScopes) { + this.handleUnauthorized(route.data?.['unauthorizedRedirect']); + return false; + } + } + return true; + } else { + this.handleUnauthenticated(redirectUrl, route.data?.['loginRedirect']); + return false; + } + }) + ); + } + + private handleUnauthenticated(redirectUrl: string, customLoginPath?: string): void { + const loginPath = customLoginPath || '/login'; + this.router.navigate([loginPath], { + queryParams: { returnUrl: redirectUrl } + }); + } + + private handleUnauthorized(customUnauthorizedPath?: string): void { + const unauthorizedPath = customUnauthorizedPath || '/unauthorized'; + this.router.navigate([unauthorizedPath]); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/guards/guest.guard.ts b/projects/auth-client/src/lib/guards/guest.guard.ts new file mode 100644 index 0000000..5340ab3 --- /dev/null +++ b/projects/auth-client/src/lib/guards/guest.guard.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { AuthService } from '../services/auth.service'; + +@Injectable({ + providedIn: 'root' +}) +export class GuestGuard implements CanActivate { + constructor( + private authService: AuthService, + private router: Router + ) {} + + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + return this.authService.isAuthenticated$.pipe( + take(1), + map(isAuthenticated => { + if (isAuthenticated) { + // User is authenticated, redirect to home or specified route + const redirectTo = route.data?.['authenticatedRedirect'] || '/'; + this.router.navigate([redirectTo]); + return false; + } + return true; + }) + ); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/interceptors/auth.interceptor.ts b/projects/auth-client/src/lib/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..ba9e5b4 --- /dev/null +++ b/projects/auth-client/src/lib/interceptors/auth.interceptor.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@angular/core'; +import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; +import { Observable, throwError, BehaviorSubject } from 'rxjs'; +import { catchError, switchMap, filter, take } from 'rxjs/operators'; +import { TokenService } from '../services/token.service'; +import { AuthHttpService } from '../services/auth-http.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private isRefreshing = false; + private refreshTokenSubject: BehaviorSubject = new BehaviorSubject(null); + + constructor( + private tokenService: TokenService, + private authHttpService: AuthHttpService + ) {} + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + // Add auth token to request if available and not already present + if (this.shouldAddToken(request)) { + request = this.addToken(request); + } + + return next.handle(request).pipe( + catchError(error => { + if (error instanceof HttpErrorResponse && error.status === 401) { + return this.handle401Error(request, next); + } + return throwError(() => error); + }) + ); + } + + /** + * Check if token should be added to this request + */ + private shouldAddToken(request: HttpRequest): boolean { + // Don't add token if already present + if (request.headers.has('Authorization')) { + return false; + } + + // Don't add token to auth endpoints that don't require it + const url = request.url.toLowerCase(); + const publicEndpoints = [ + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/validate', + '/users/forgot-password', + '/users/reset-password', + '/users/verify-email', + '/oauth/providers', + '/oauth/', + '/health' + ]; + + return !publicEndpoints.some(endpoint => url.includes(endpoint)); + } + + /** + * Add authentication token to request + */ + private addToken(request: HttpRequest): HttpRequest { + const authHeader = this.tokenService.getAuthorizationHeader(); + + if (authHeader) { + return request.clone({ + setHeaders: { + Authorization: authHeader + } + }); + } + + return request; + } + + /** + * Handle 401 errors by attempting token refresh + */ + private handle401Error(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.isRefreshing) { + this.isRefreshing = true; + this.refreshTokenSubject.next(null); + + const refreshToken = this.tokenService.getRefreshToken(); + + if (refreshToken) { + return this.authHttpService.refreshToken({ refresh_token: refreshToken }).pipe( + switchMap((tokenPair) => { + this.isRefreshing = false; + this.tokenService.setTokens(tokenPair); + this.refreshTokenSubject.next(tokenPair.access_token); + + // Retry original request with new token + return next.handle(this.addToken(request)); + }), + catchError((error) => { + this.isRefreshing = false; + this.tokenService.clearTokens(); + return throwError(() => error); + }) + ); + } else { + this.isRefreshing = false; + this.tokenService.clearTokens(); + return throwError(() => new Error('No refresh token available')); + } + } else { + // If refresh is in progress, wait for it to complete + return this.refreshTokenSubject.pipe( + filter(token => token != null), + take(1), + switchMap(() => next.handle(this.addToken(request))) + ); + } + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/models/auth.models.ts b/projects/auth-client/src/lib/models/auth.models.ts new file mode 100644 index 0000000..f58cb48 --- /dev/null +++ b/projects/auth-client/src/lib/models/auth.models.ts @@ -0,0 +1,257 @@ +export interface LoginRequest { + email: string; + password: string; + totp_code?: string; + backup_code?: string; +} + +export interface RegisterRequest { + email: string; + password: string; + password_confirmation?: string; + first_name?: string; + last_name?: string; +} + +export interface TokenPair { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scopes?: string[]; +} + +export interface LoginResponse extends TokenPair { + user: User; +} + +export interface RegisterResponse extends TokenPair { + user: User; +} + +export interface User { + id: string; + email: string; + first_name?: string; + last_name?: string; + is_active: boolean; + email_verified: boolean; + profile_data?: Record; + created_at: string; + updated_at: string; +} + +export interface TokenValidationRequest { + token: string; +} + +export interface TokenValidationResponse { + valid: boolean; + user_id?: string; + email?: string; + scopes?: string[]; + organization?: string; + expires_at?: number; + reason?: string; +} + +export interface RefreshTokenRequest { + refresh_token: string; +} + +export interface LogoutRequest { + refresh_token?: string; +} + +export interface PasswordResetRequest { + email: string; +} + +export interface PasswordResetConfirmRequest { + token: string; + password: string; + password_confirmation: string; +} + +export interface ChangePasswordRequest { + current_password: string; + new_password: string; + new_password_confirmation: string; +} + +export interface EmailVerificationRequest { + token: string; +} + +export interface ResendVerificationRequest { + email: string; +} + +export interface TwoFactorSetupResponse { + secret: string; + qr_code: string; + backup_codes: string[]; +} + +export interface TwoFactorVerifyRequest { + token: string; +} + +export interface TwoFactorStatusResponse { + enabled: boolean; + backup_codes_remaining?: number; +} + +export interface ApiError { + error: string; + details?: Record; + requires_2fa?: boolean; +} + +export interface ApiResponse { + data?: T; + error?: ApiError; + message?: string; +} + +export interface OAuthProvider { + name: string; + display_name: string; + authorization_url?: string; +} + +export interface OAuthProvidersResponse { + providers: OAuthProvider[]; +} + +export interface OAuthLinkRequest { + provider: string; + code: string; + state?: string; +} + +export interface OrganizationMember { + id: string; + user_id: string; + email: string; + first_name?: string; + last_name?: string; + role: string; + joined_at: string; +} + +export interface Organization { + id: string; + name: string; + description?: string; + settings?: Record; + created_at: string; + updated_at: string; +} + +export interface CreateOrganizationRequest { + name: string; + description?: string; +} + +export interface UpdateOrganizationRequest { + name?: string; + description?: string; +} + +export interface InviteMemberRequest { + email: string; + role: string; +} + +export interface UpdateMemberRoleRequest { + role: string; +} + +export interface Service { + id: string; + name: string; + description?: string; + permissions: string[]; + validation_mode: 'trust_gateway' | 'validate_sensitive' | 'always_validate'; + created_at: string; + updated_at: string; +} + +export interface UserPermissions { + service_id: string; + service_name: string; + permissions: string[]; +} + +export interface ApiKey { + id: string; + name: string; + key_prefix: string; + scopes: string[]; + is_active: boolean; + last_used_at?: string; + expires_at?: string; + created_at: string; + updated_at: string; +} + +export interface CreateApiKeyRequest { + name: string; + scopes: string[]; + expires_at?: string; +} + +export interface UpdateApiKeyRequest { + name?: string; + scopes?: string[]; + expires_at?: string; +} + +export interface ApiKeyUsageStats { + total_requests: number; + requests_today: number; + requests_this_week: number; + requests_this_month: number; + last_request_at?: string; +} + +export interface AuditLog { + id: string; + action: string; + resource_type?: string; + resource_id?: string; + details?: Record; + ip_address?: string; + user_agent?: string; + status: 'success' | 'failure'; + created_at: string; +} + +export interface LoginAttempt { + id: string; + email: string; + ip_address: string; + user_agent?: string; + status: 'success' | 'failure'; + failure_reason?: string; + created_at: string; +} + +export interface SecurityStats { + total_users: number; + active_sessions: number; + failed_logins_today: number; + blocked_ips: number; + two_fa_enabled_users: number; +} + +export interface RateLimit { + identifier: string; + identifier_type: 'ip' | 'user' | 'api_key'; + endpoint: string; + requests_count: number; + window_start: string; + is_blocked: boolean; + blocked_until?: string; +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/services/auth-http.service.ts b/projects/auth-client/src/lib/services/auth-http.service.ts new file mode 100644 index 0000000..1cb673b --- /dev/null +++ b/projects/auth-client/src/lib/services/auth-http.service.ts @@ -0,0 +1,331 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { TokenService } from './token.service'; +import { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + TokenValidationRequest, + TokenValidationResponse, + RefreshTokenRequest, + TokenPair, + LogoutRequest, + ApiResponse, + ApiError, + PasswordResetRequest, + PasswordResetConfirmRequest, + ChangePasswordRequest, + EmailVerificationRequest, + ResendVerificationRequest, + User, + TwoFactorSetupResponse, + TwoFactorVerifyRequest, + TwoFactorStatusResponse +} from '../models/auth.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthHttpService { + private baseUrl = ''; + + constructor( + private http: HttpClient, + private tokenService: TokenService + ) {} + + /** + * Set the base URL for the auth service + */ + setBaseUrl(url: string): void { + this.baseUrl = url.replace(/\/$/, ''); // Remove trailing slash + } + + /** + * Get API URL with version prefix + */ + private getApiUrl(path: string): string { + return `${this.baseUrl}/api/v1${path}`; + } + + /** + * Get HTTP headers with authorization if available + */ + private getHeaders(includeAuth = true): HttpHeaders { + let headers = new HttpHeaders({ + 'Content-Type': 'application/json' + }); + + if (includeAuth) { + const authHeader = this.tokenService.getAuthorizationHeader(); + if (authHeader) { + headers = headers.set('Authorization', authHeader); + } + } + + return headers; + } + + /** + * Handle HTTP errors and extract API error information + */ + private handleError(error: any): Observable { + let apiError: ApiError; + + if (error.error && typeof error.error === 'object') { + apiError = error.error; + } else { + apiError = { + error: error.message || 'An unknown error occurred' + }; + } + + return throwError(() => apiError); + } + + // Authentication Endpoints + + /** + * Register a new user + */ + register(request: RegisterRequest): Observable { + return this.http.post( + this.getApiUrl('/auth/register'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Login with email and password + */ + login(request: LoginRequest): Observable { + return this.http.post( + this.getApiUrl('/auth/login'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Refresh access token + */ + refreshToken(request: RefreshTokenRequest): Observable { + return this.http.post( + this.getApiUrl('/auth/refresh'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Validate token (for API Gateway) + */ + validateToken(request: TokenValidationRequest): Observable { + return this.http.post( + this.getApiUrl('/auth/validate'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Logout user + */ + logout(request?: LogoutRequest): Observable { + return this.http.post( + this.getApiUrl('/auth/logout'), + request || {}, + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Get current user information + */ + getCurrentUser(): Observable { + return this.http.get<{ user: User }>( + this.getApiUrl('/auth/me'), + { headers: this.getHeaders() } + ).pipe( + map(response => response.user), + catchError(this.handleError) + ); + } + + // User Management Endpoints + + /** + * Get user profile + */ + getUserProfile(): Observable { + return this.http.get<{ user: User }>( + this.getApiUrl('/users/profile'), + { headers: this.getHeaders() } + ).pipe( + map(response => response.user), + catchError(this.handleError) + ); + } + + /** + * Update user profile + */ + updateUserProfile(updates: Partial): Observable { + return this.http.put<{ user: User }>( + this.getApiUrl('/users/profile'), + updates, + { headers: this.getHeaders() } + ).pipe( + map(response => response.user), + catchError(this.handleError) + ); + } + + /** + * Change password + */ + changePassword(request: ChangePasswordRequest): Observable { + return this.http.post( + this.getApiUrl('/users/change-password'), + request, + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Request password reset + */ + forgotPassword(request: PasswordResetRequest): Observable { + return this.http.post( + this.getApiUrl('/users/forgot-password'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Reset password with token + */ + resetPassword(request: PasswordResetConfirmRequest): Observable { + return this.http.post( + this.getApiUrl('/users/reset-password'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Verify email with token + */ + verifyEmail(request: EmailVerificationRequest): Observable { + return this.http.post( + this.getApiUrl('/users/verify-email'), + request, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Resend email verification + */ + resendEmailVerification(request?: ResendVerificationRequest): Observable { + const url = request + ? this.getApiUrl('/users/resend-verification') + : this.getApiUrl('/users/resend-verification'); + + const body = request || {}; + const headers = request ? this.getHeaders(false) : this.getHeaders(); + + return this.http.post(url, body, { headers }).pipe( + catchError(this.handleError) + ); + } + + // Two-Factor Authentication + + /** + * Setup 2FA for user + */ + setup2FA(): Observable { + return this.http.post( + this.getApiUrl('/users/2fa/setup'), + {}, + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Verify 2FA setup + */ + verify2FASetup(request: TwoFactorVerifyRequest): Observable { + return this.http.post( + this.getApiUrl('/users/2fa/verify'), + request, + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Disable 2FA + */ + disable2FA(): Observable { + return this.http.delete( + this.getApiUrl('/users/2fa'), + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Check 2FA status + */ + get2FAStatus(): Observable { + return this.http.get( + this.getApiUrl('/security/2fa/status'), + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + // Health Check + + /** + * Check service health + */ + healthCheck(): Observable { + return this.http.get( + `${this.baseUrl}/health`, + { headers: this.getHeaders(false) } + ).pipe( + catchError(this.handleError) + ); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/services/auth.service.ts b/projects/auth-client/src/lib/services/auth.service.ts new file mode 100644 index 0000000..fff3e32 --- /dev/null +++ b/projects/auth-client/src/lib/services/auth.service.ts @@ -0,0 +1,408 @@ +import { Injectable } from '@angular/core'; +import { Observable, BehaviorSubject, throwError, timer, EMPTY } from 'rxjs'; +import { switchMap, tap, catchError, filter, take, shareReplay } from 'rxjs/operators'; +import { AuthHttpService } from './auth-http.service'; +import { TokenService } from './token.service'; +import { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, + User, + TokenPair, + LogoutRequest, + PasswordResetRequest, + PasswordResetConfirmRequest, + ChangePasswordRequest, + EmailVerificationRequest, + ResendVerificationRequest, + TwoFactorSetupResponse, + TwoFactorVerifyRequest, + TwoFactorStatusResponse, + ApiResponse, + ApiError +} from '../models/auth.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private currentUserSubject = new BehaviorSubject(null); + private isAuthenticatedSubject = new BehaviorSubject(false); + private isLoadingSubject = new BehaviorSubject(false); + + public currentUser$ = this.currentUserSubject.asObservable(); + public isAuthenticated$ = this.isAuthenticatedSubject.asObservable(); + public isLoading$ = this.isLoadingSubject.asObservable(); + + private refreshTimer?: any; + + constructor( + private authHttpService: AuthHttpService, + private tokenService: TokenService + ) { + this.initialize(); + } + + /** + * Initialize auth service + */ + private initialize(): void { + // Initialize storage listener for cross-tab sync + this.tokenService.initStorageListener(); + + // Subscribe to token changes + this.tokenService.token$.subscribe(token => { + const isAuthenticated = !!token && this.tokenService.isTokenValid(); + this.isAuthenticatedSubject.next(isAuthenticated); + + if (isAuthenticated) { + this.loadCurrentUser(); + this.scheduleTokenRefresh(); + } else { + this.currentUserSubject.next(null); + this.clearRefreshTimer(); + } + }); + + // Check initial authentication state + if (this.tokenService.isTokenValid()) { + this.isAuthenticatedSubject.next(true); + this.loadCurrentUser(); + this.scheduleTokenRefresh(); + } + } + + /** + * Configure auth service with base URL + */ + configure(baseUrl: string): void { + this.authHttpService.setBaseUrl(baseUrl); + } + + // Authentication Methods + + /** + * Register new user + */ + register(request: RegisterRequest): Observable { + this.isLoadingSubject.next(true); + + return this.authHttpService.register(request).pipe( + tap(response => { + this.tokenService.setTokens(response); + this.currentUserSubject.next(response.user); + this.isAuthenticatedSubject.next(true); + this.scheduleTokenRefresh(); + }), + catchError(error => { + this.isLoadingSubject.next(false); + return throwError(() => error); + }), + tap(() => this.isLoadingSubject.next(false)) + ); + } + + /** + * Login user + */ + login(request: LoginRequest): Observable { + this.isLoadingSubject.next(true); + + return this.authHttpService.login(request).pipe( + tap(response => { + this.tokenService.setTokens(response); + this.currentUserSubject.next(response.user); + this.isAuthenticatedSubject.next(true); + this.scheduleTokenRefresh(); + }), + catchError(error => { + this.isLoadingSubject.next(false); + return throwError(() => error); + }), + tap(() => this.isLoadingSubject.next(false)) + ); + } + + /** + * Logout user + */ + logout(revokeRefreshToken = true): Observable { + this.isLoadingSubject.next(true); + + const logoutRequest: LogoutRequest = revokeRefreshToken + ? { refresh_token: this.tokenService.getRefreshToken() || undefined } + : {}; + + return this.authHttpService.logout(logoutRequest).pipe( + tap(() => { + this.clearAuthState(); + }), + catchError(error => { + // Even if logout fails on server, clear local state + this.clearAuthState(); + this.isLoadingSubject.next(false); + return throwError(() => error); + }), + tap(() => this.isLoadingSubject.next(false)) + ); + } + + /** + * Silently logout (clear local state only) + */ + logoutSilently(): void { + this.clearAuthState(); + } + + /** + * Refresh access token + */ + refreshToken(): Observable { + const refreshToken = this.tokenService.getRefreshToken(); + + if (!refreshToken) { + this.logoutSilently(); + return throwError(() => ({ error: 'No refresh token available' } as ApiError)); + } + + return this.authHttpService.refreshToken({ refresh_token: refreshToken }).pipe( + tap(tokenPair => { + this.tokenService.setTokens(tokenPair); + this.scheduleTokenRefresh(); + }), + catchError(error => { + // If refresh fails, logout user + this.logoutSilently(); + return throwError(() => error); + }) + ); + } + + /** + * Get current user information + */ + getCurrentUser(): Observable { + if (this.currentUserSubject.value) { + return this.currentUserSubject.asObservable().pipe( + filter(user => !!user), + take(1) + ); + } + + return this.loadCurrentUser(); + } + + /** + * Load current user from server + */ + private loadCurrentUser(): Observable { + return this.authHttpService.getCurrentUser().pipe( + tap(user => this.currentUserSubject.next(user)), + catchError(error => { + // If getting current user fails, might be invalid token + if (error.status === 401) { + this.logoutSilently(); + } + return throwError(() => error); + }), + shareReplay(1) + ); + } + + // User Management + + /** + * Update user profile + */ + updateProfile(updates: Partial): Observable { + return this.authHttpService.updateUserProfile(updates).pipe( + tap(user => this.currentUserSubject.next(user)) + ); + } + + /** + * Change password + */ + changePassword(request: ChangePasswordRequest): Observable { + return this.authHttpService.changePassword(request); + } + + /** + * Request password reset + */ + forgotPassword(request: PasswordResetRequest): Observable { + return this.authHttpService.forgotPassword(request); + } + + /** + * Reset password with token + */ + resetPassword(request: PasswordResetConfirmRequest): Observable { + return this.authHttpService.resetPassword(request); + } + + /** + * Verify email + */ + verifyEmail(request: EmailVerificationRequest): Observable { + return this.authHttpService.verifyEmail(request); + } + + /** + * Resend email verification + */ + resendEmailVerification(request?: ResendVerificationRequest): Observable { + return this.authHttpService.resendEmailVerification(request); + } + + // Two-Factor Authentication + + /** + * Setup 2FA + */ + setup2FA(): Observable { + return this.authHttpService.setup2FA(); + } + + /** + * Verify 2FA setup + */ + verify2FASetup(request: TwoFactorVerifyRequest): Observable { + return this.authHttpService.verify2FASetup(request); + } + + /** + * Disable 2FA + */ + disable2FA(): Observable { + return this.authHttpService.disable2FA(); + } + + /** + * Get 2FA status + */ + get2FAStatus(): Observable { + return this.authHttpService.get2FAStatus(); + } + + // Token Management + + /** + * Check if user is authenticated + */ + isAuthenticated(): boolean { + return this.isAuthenticatedSubject.value; + } + + /** + * Get access token + */ + getAccessToken(): string | null { + return this.tokenService.getAccessToken(); + } + + /** + * Check if user has specific scope + */ + hasScope(scope: string): boolean { + return this.tokenService.hasScope(scope); + } + + /** + * Check if user has any of the specified scopes + */ + hasAnyScope(scopes: string[]): boolean { + return this.tokenService.hasAnyScope(scopes); + } + + /** + * Check if user has all of the specified scopes + */ + hasAllScopes(scopes: string[]): boolean { + return this.tokenService.hasAllScopes(scopes); + } + + /** + * Get user scopes + */ + getUserScopes(): string[] { + return this.tokenService.getUserScopes(); + } + + /** + * Get user ID from token + */ + getUserId(): string | null { + return this.tokenService.getUserId(); + } + + /** + * Get user email from token + */ + getUserEmail(): string | null { + return this.tokenService.getUserEmail(); + } + + /** + * Get user organization from token + */ + getUserOrganization(): string | null { + return this.tokenService.getUserOrganization(); + } + + // Private Methods + + /** + * Clear authentication state + */ + private clearAuthState(): void { + this.tokenService.clearTokens(); + this.currentUserSubject.next(null); + this.isAuthenticatedSubject.next(false); + this.clearRefreshTimer(); + } + + /** + * Schedule automatic token refresh + */ + private scheduleTokenRefresh(): void { + this.clearRefreshTimer(); + + const timeUntilExpiry = this.tokenService.getTimeUntilExpiry(); + if (timeUntilExpiry <= 0) return; + + // Refresh token 2 minutes before expiry + const refreshTime = Math.max(1000, timeUntilExpiry - (2 * 60 * 1000)); + + this.refreshTimer = timer(refreshTime).pipe( + switchMap(() => { + if (this.tokenService.isTokenExpiringSoon()) { + return this.refreshToken(); + } + return EMPTY; + }), + catchError(error => { + console.warn('Auto token refresh failed:', error); + return EMPTY; + }) + ).subscribe(); + } + + /** + * Clear refresh timer + */ + private clearRefreshTimer(): void { + if (this.refreshTimer) { + this.refreshTimer.unsubscribe(); + this.refreshTimer = undefined; + } + } + + /** + * Check service health + */ + healthCheck(): Observable { + return this.authHttpService.healthCheck(); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/services/oauth.service.ts b/projects/auth-client/src/lib/services/oauth.service.ts new file mode 100644 index 0000000..eebf6ad --- /dev/null +++ b/projects/auth-client/src/lib/services/oauth.service.ts @@ -0,0 +1,290 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { TokenService } from './token.service'; +import { + OAuthProvider, + OAuthProvidersResponse, + OAuthLinkRequest, + ApiResponse, + ApiError +} from '../models/auth.models'; + +@Injectable({ + providedIn: 'root' +}) +export class OAuthService { + private baseUrl = ''; + + constructor( + private http: HttpClient, + private tokenService: TokenService + ) {} + + /** + * Set the base URL for the auth service + */ + setBaseUrl(url: string): void { + this.baseUrl = url.replace(/\/$/, ''); // Remove trailing slash + } + + /** + * Get API URL with version prefix + */ + private getApiUrl(path: string): string { + return `${this.baseUrl}/api/v1${path}`; + } + + /** + * Get HTTP headers with authorization if available + */ + private getHeaders(includeAuth = true): { [header: string]: string } { + const headers: { [header: string]: string } = { + 'Content-Type': 'application/json' + }; + + if (includeAuth) { + const authHeader = this.tokenService.getAuthorizationHeader(); + if (authHeader) { + headers['Authorization'] = authHeader; + } + } + + return headers; + } + + /** + * Handle HTTP errors and extract API error information + */ + private handleError(error: any): Observable { + let apiError: ApiError; + + if (error.error && typeof error.error === 'object') { + apiError = error.error; + } else { + apiError = { + error: error.message || 'An unknown error occurred' + }; + } + + return throwError(() => apiError); + } + + /** + * Get available OAuth providers + */ + getProviders(): Observable { + return this.http.get( + this.getApiUrl('/oauth/providers'), + { headers: this.getHeaders(false) } + ).pipe( + map(response => response.providers), + catchError(this.handleError) + ); + } + + /** + * Get OAuth authorization URL for a provider + */ + getAuthorizationUrl(provider: string, redirectUri?: string, state?: string): Observable { + let url = this.getApiUrl(`/oauth/${provider}`); + + const params = new URLSearchParams(); + if (redirectUri) { + params.append('redirect_uri', redirectUri); + } + if (state) { + params.append('state', state); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + return this.http.get(url, { + headers: this.getHeaders(false), + responseType: 'text' + }).pipe( + catchError(this.handleError) + ); + } + + /** + * Handle OAuth callback (exchange code for tokens) + */ + handleCallback(provider: string, code: string, state?: string): Observable { + const params = new URLSearchParams(); + params.append('code', code); + if (state) { + params.append('state', state); + } + + const url = `${this.getApiUrl(`/oauth/${provider}/callback`)}?${params.toString()}`; + + return this.http.get(url, { + headers: this.getHeaders(false) + }).pipe( + catchError(this.handleError) + ); + } + + /** + * Link OAuth provider to existing account (requires authentication) + */ + linkProvider(provider: string, code: string, state?: string): Observable { + const request: OAuthLinkRequest = { + provider, + code, + state + }; + + return this.http.post( + this.getApiUrl(`/oauth/${provider}/link`), + request, + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Unlink OAuth provider from account (requires authentication) + */ + unlinkProvider(provider: string): Observable { + return this.http.delete( + this.getApiUrl(`/oauth/${provider}`), + { headers: this.getHeaders() } + ).pipe( + catchError(this.handleError) + ); + } + + /** + * Initiate OAuth flow by redirecting to provider + */ + initiateOAuthFlow(provider: string, redirectUri?: string, state?: string): void { + this.getAuthorizationUrl(provider, redirectUri, state).subscribe({ + next: (authUrl) => { + window.location.href = authUrl; + }, + error: (error) => { + console.error('Failed to initiate OAuth flow:', error); + throw error; + } + }); + } + + /** + * Open OAuth flow in popup window + */ + initiateOAuthPopup( + provider: string, + redirectUri?: string, + state?: string, + popupFeatures = 'width=500,height=600,scrollbars=yes,resizable=yes' + ): Promise { + return new Promise((resolve, reject) => { + this.getAuthorizationUrl(provider, redirectUri, state).subscribe({ + next: (authUrl) => { + const popup = window.open(authUrl, 'oauth', popupFeatures); + + if (!popup) { + reject(new Error('Failed to open popup window')); + return; + } + + // Poll for popup closure or message + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + reject(new Error('OAuth popup was closed by user')); + } + }, 1000); + + // Listen for messages from popup + const messageListener = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + + if (event.data.type === 'OAUTH_SUCCESS') { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + popup.close(); + resolve(event.data.payload); + } else if (event.data.type === 'OAUTH_ERROR') { + clearInterval(checkClosed); + window.removeEventListener('message', messageListener); + popup.close(); + reject(new Error(event.data.error)); + } + }; + + window.addEventListener('message', messageListener); + }, + error: (error) => { + reject(error); + } + }); + }); + } + + /** + * Generate state parameter for OAuth security + */ + generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + } + + /** + * Store state in session storage for verification + */ + storeState(state: string): void { + sessionStorage.setItem('oauth_state', state); + } + + /** + * Verify state parameter + */ + verifyState(state: string): boolean { + const storedState = sessionStorage.getItem('oauth_state'); + sessionStorage.removeItem('oauth_state'); + return storedState === state; + } + + /** + * Extract code and state from URL params (for redirect handling) + */ + extractCallbackParams(): { code?: string; state?: string; error?: string } { + const urlParams = new URLSearchParams(window.location.search); + return { + code: urlParams.get('code') || undefined, + state: urlParams.get('state') || undefined, + error: urlParams.get('error') || undefined + }; + } + + /** + * Complete OAuth flow with extracted parameters + */ + completeOAuthFlow(provider: string): Observable { + const params = this.extractCallbackParams(); + + if (params.error) { + return throwError(() => ({ error: params.error } as ApiError)); + } + + if (!params.code) { + return throwError(() => ({ error: 'No authorization code received' } as ApiError)); + } + + if (params.state && !this.verifyState(params.state)) { + return throwError(() => ({ error: 'Invalid state parameter' } as ApiError)); + } + + return this.handleCallback(provider, params.code, params.state); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/lib/services/token.service.ts b/projects/auth-client/src/lib/services/token.service.ts new file mode 100644 index 0000000..08286d7 --- /dev/null +++ b/projects/auth-client/src/lib/services/token.service.ts @@ -0,0 +1,226 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { TokenPair } from '../models/auth.models'; + +@Injectable({ + providedIn: 'root' +}) +export class TokenService { + private readonly ACCESS_TOKEN_KEY = 'auth_access_token'; + private readonly REFRESH_TOKEN_KEY = 'auth_refresh_token'; + private readonly TOKEN_TYPE_KEY = 'auth_token_type'; + private readonly EXPIRES_AT_KEY = 'auth_expires_at'; + + private tokenSubject = new BehaviorSubject(this.getAccessToken()); + public token$ = this.tokenSubject.asObservable(); + + constructor() { + // Check token validity on service initialization + this.checkTokenValidity(); + } + + /** + * Store token pair in localStorage + */ + setTokens(tokenPair: TokenPair): void { + const expiresAt = Date.now() + (tokenPair.expires_in * 1000); + + localStorage.setItem(this.ACCESS_TOKEN_KEY, tokenPair.access_token); + localStorage.setItem(this.REFRESH_TOKEN_KEY, tokenPair.refresh_token); + localStorage.setItem(this.TOKEN_TYPE_KEY, tokenPair.token_type); + localStorage.setItem(this.EXPIRES_AT_KEY, expiresAt.toString()); + + this.tokenSubject.next(tokenPair.access_token); + } + + /** + * Get access token from localStorage + */ + getAccessToken(): string | null { + return localStorage.getItem(this.ACCESS_TOKEN_KEY); + } + + /** + * Get refresh token from localStorage + */ + getRefreshToken(): string | null { + return localStorage.getItem(this.REFRESH_TOKEN_KEY); + } + + /** + * Get token type from localStorage + */ + getTokenType(): string { + return localStorage.getItem(this.TOKEN_TYPE_KEY) || 'Bearer'; + } + + /** + * Get token expiration timestamp + */ + getExpiresAt(): number | null { + const expiresAt = localStorage.getItem(this.EXPIRES_AT_KEY); + return expiresAt ? parseInt(expiresAt, 10) : null; + } + + /** + * Check if token exists + */ + hasToken(): boolean { + return this.getAccessToken() !== null; + } + + /** + * Check if token is expired + */ + isTokenExpired(): boolean { + const expiresAt = this.getExpiresAt(); + if (!expiresAt) return true; + + // Add 30 second buffer to account for clock skew + return Date.now() >= (expiresAt - 30000); + } + + /** + * Check if token is valid (exists and not expired) + */ + isTokenValid(): boolean { + return this.hasToken() && !this.isTokenExpired(); + } + + /** + * Get authorization header value + */ + getAuthorizationHeader(): string | null { + const token = this.getAccessToken(); + const tokenType = this.getTokenType(); + + return token ? `${tokenType} ${token}` : null; + } + + /** + * Clear all tokens from storage + */ + clearTokens(): void { + localStorage.removeItem(this.ACCESS_TOKEN_KEY); + localStorage.removeItem(this.REFRESH_TOKEN_KEY); + localStorage.removeItem(this.TOKEN_TYPE_KEY); + localStorage.removeItem(this.EXPIRES_AT_KEY); + + this.tokenSubject.next(null); + } + + /** + * Get time until token expires (in milliseconds) + */ + getTimeUntilExpiry(): number { + const expiresAt = this.getExpiresAt(); + if (!expiresAt) return 0; + + const timeLeft = expiresAt - Date.now(); + return Math.max(0, timeLeft); + } + + /** + * Check if token will expire soon (within 5 minutes) + */ + isTokenExpiringSoon(): boolean { + const timeLeft = this.getTimeUntilExpiry(); + return timeLeft > 0 && timeLeft < 5 * 60 * 1000; // 5 minutes + } + + /** + * Decode JWT token payload (without verification) + */ + decodeToken(token?: string): any { + const tokenToDecoded = token || this.getAccessToken(); + if (!tokenToDecoded) return null; + + try { + const payload = tokenToDecoded.split('.')[1]; + const decoded = atob(payload); + return JSON.parse(decoded); + } catch (error) { + console.warn('Failed to decode token:', error); + return null; + } + } + + /** + * Get user ID from token + */ + getUserId(): string | null { + const payload = this.decodeToken(); + return payload?.sub || null; + } + + /** + * Get user email from token + */ + getUserEmail(): string | null { + const payload = this.decodeToken(); + return payload?.email || null; + } + + /** + * Get user scopes from token + */ + getUserScopes(): string[] { + const payload = this.decodeToken(); + return payload?.scopes || []; + } + + /** + * Get user organization from token + */ + getUserOrganization(): string | null { + const payload = this.decodeToken(); + return payload?.organization || null; + } + + /** + * Check if user has specific scope + */ + hasScope(scope: string): boolean { + const scopes = this.getUserScopes(); + return scopes.includes(scope); + } + + /** + * Check if user has any of the specified scopes + */ + hasAnyScope(scopes: string[]): boolean { + const userScopes = this.getUserScopes(); + return scopes.some(scope => userScopes.includes(scope)); + } + + /** + * Check if user has all of the specified scopes + */ + hasAllScopes(scopes: string[]): boolean { + const userScopes = this.getUserScopes(); + return scopes.every(scope => userScopes.includes(scope)); + } + + /** + * Check token validity and clear if invalid + */ + private checkTokenValidity(): void { + if (this.hasToken() && this.isTokenExpired()) { + this.clearTokens(); + } + } + + /** + * Subscribe to storage events for cross-tab synchronization + */ + initStorageListener(): void { + window.addEventListener('storage', (event) => { + if (event.key === this.ACCESS_TOKEN_KEY) { + this.tokenSubject.next(event.newValue); + } else if (event.key === null) { + // Storage was cleared + this.tokenSubject.next(null); + } + }); + } +} \ No newline at end of file diff --git a/projects/auth-client/src/public-api.ts b/projects/auth-client/src/public-api.ts new file mode 100644 index 0000000..3818536 --- /dev/null +++ b/projects/auth-client/src/public-api.ts @@ -0,0 +1,22 @@ +/* + * Public API Surface of auth-client + */ + +// Models +export * from './lib/models/auth.models'; + +// Services +export * from './lib/services/auth.service'; +export * from './lib/services/auth-http.service'; +export * from './lib/services/token.service'; +export * from './lib/services/oauth.service'; + +// Guards +export * from './lib/guards/auth.guard'; +export * from './lib/guards/guest.guard'; + +// Interceptors +export * from './lib/interceptors/auth.interceptor'; + +// Legacy export for compatibility +export * from './lib/auth-client'; diff --git a/projects/auth-client/tsconfig.lib.json b/projects/auth-client/tsconfig.lib.json new file mode 100644 index 0000000..edb0551 --- /dev/null +++ b/projects/auth-client/tsconfig.lib.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/projects/auth-client/tsconfig.lib.prod.json b/projects/auth-client/tsconfig.lib.prod.json new file mode 100644 index 0000000..9215caa --- /dev/null +++ b/projects/auth-client/tsconfig.lib.prod.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/projects/auth-client/tsconfig.spec.json b/projects/auth-client/tsconfig.spec.json new file mode 100644 index 0000000..0feea88 --- /dev/null +++ b/projects/auth-client/tsconfig.spec.json @@ -0,0 +1,14 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 5ff9191..310aa8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,9 @@ "experimentalDecorators": true, "moduleResolution": "bundler", "paths": { + "auth-client": [ + "./dist/auth-client" + ], "hcl-studio": [ "./dist/hcl-studio" ], @@ -58,5 +61,13 @@ "strictInjectionParameters": true, "strictInputAccessModifiers": true, "strictTemplates": true - } + }, + "references": [ + { + "path": "./projects/auth-client/tsconfig.lib.json" + }, + { + "path": "./projects/auth-client/tsconfig.spec.json" + } + ] }