feat(auth): implement Google OAuth login flow and handle session clearing on 401

This commit is contained in:
2026-07-22 17:01:11 -03:00
parent c662c27fc7
commit 20c8b04638
4 changed files with 92 additions and 13 deletions

View File

@@ -96,7 +96,7 @@ describe('AuthService', () => {
httpController.verify();
});
it('propagates a 401 from /me without clearing the session', async () => {
it('clears an expired session when /me returns 401', async () => {
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
@@ -117,11 +117,11 @@ describe('AuthService', () => {
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
await expect(bootstrapPromise).resolves.toBeUndefined();
expect(service.user()).toBeNull();
expect(service.token()).toBe('expired-token');
expect(service.isAuthenticated()).toBe(true);
expect(cookieStore['shopit.auth.token']).toBe('expired-token');
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});

View File

@@ -1,4 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { DOCUMENT } from '@angular/common';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
@@ -23,6 +24,7 @@ const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
})
export class AuthService {
private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
@@ -51,6 +53,27 @@ export class AuthService {
});
}
loginWithGoogle(): void {
const tenant = this.tenantService.getTenant();
if (!tenant) {
throw new Error('No se pudo resolver el tenant activo.');
}
const apiUrl = new URL(environment.url);
const authorizationUrl = new URL('/auth/google/redirect', apiUrl.origin);
authorizationUrl.searchParams.set('tenant', tenant.codigo);
authorizationUrl.searchParams.set('return_url', this.document.location.origin);
this.document.location.assign(authorizationUrl.toString());
}
completeGoogleLogin(oauthCode: string): Observable<AuthUser> {
return this.http.post<LoginResponse>(`${environment.url}auth/google/exchange`, { oauth_code: oauthCode }).pipe(
tap((response) => this.applyAuthenticatedState(response.token, response.user)),
map((response) => response.user)
);
}
updateProfile(payload: UpdateProfilePayload): Observable<AuthUser> {
return this.http.put<AuthUser>(`${environment.url}me`, payload).pipe(
tap((user) => this.userState.set(user))
@@ -82,9 +105,24 @@ export class AuthService {
return;
}
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
try {
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
// An expired token is an expected state, including while rendering on the
// server. Do not let it reject the application initializer: Angular's SSR
// pipeline can otherwise attempt to serialize HttpErrorResponse internals
// as HTTP headers.
if (error instanceof HttpErrorResponse && error.status === 401) {
this.clearSession();
return;
}
throw error;
}
}

View File

@@ -81,7 +81,8 @@
variant="neutral-outline"
hostClass="w-100 d-block"
buttonClass="w-100 d-inline-flex align-items-center justify-content-center gap-2 py-2 fw-semibold"
[disabled]="true"
[disabled]="isSubmitting()"
(click)="loginWithGoogle()"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,7 +1,7 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, PLATFORM_ID, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from '../../../../core/services/auth/auth.service';
import { ButtonComponent } from '../../../../shared/components/button/button.component';
@@ -20,8 +20,10 @@ const EMAIL_MAX_LENGTH = 255;
export class LoginPageComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly authService = inject(AuthService);
private readonly document = inject(DOCUMENT);
private readonly platformId = inject(PLATFORM_ID);
private readonly submittedState = signal(false);
private readonly serverErrorState = signal<string | null>(null);
@@ -35,6 +37,18 @@ export class LoginPageComponent {
protected readonly serverError = this.serverErrorState.asReadonly();
protected readonly isSubmitting = this.isSubmittingState.asReadonly();
ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const oauthCode = this.route.snapshot.queryParamMap.get('oauth_code');
if (oauthCode) {
this.completeGoogleLogin(oauthCode);
}
}
goToRegister(): void {
void this.router.navigate(['/register']);
}
@@ -62,6 +76,16 @@ export class LoginPageComponent {
});
}
loginWithGoogle(): void {
this.serverErrorState.set(null);
try {
this.authService.loginWithGoogle();
} catch (error: unknown) {
this.serverErrorState.set(this.resolveErrorMessage(error));
}
}
protected updateEmail(value: string | number): void {
this.form.controls.email.setValue(String(value));
}
@@ -107,6 +131,22 @@ export class LoginPageComponent {
this.document.location.assign(homeUrl);
}
private completeGoogleLogin(oauthCode: string): void {
this.isSubmittingState.set(true);
this.serverErrorState.set(null);
this.authService.completeGoogleLogin(oauthCode).subscribe({
next: () => {
this.isSubmittingState.set(false);
this.redirectToHome();
},
error: (error: unknown) => {
this.isSubmittingState.set(false);
this.serverErrorState.set(this.resolveErrorMessage(error));
}
});
}
private resolveErrorMessage(error: unknown): string {
const errorPayload =
typeof error === 'object' && error !== null && 'error' in error