gogole botao
This commit is contained in:
parent
ebd666b361
commit
ffabc734f6
@ -17,5 +17,10 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
|
||||
DEMO_OPERATOR_EMAIL=ursula.b@example.com
|
||||
DEMO_OPERATOR_PASSWORD=SinkaOperador!1
|
||||
|
||||
# OAuth Google (console: origem http://localhost:3000, callback abaixo)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
|
||||
|
||||
# WEB
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
90
api/package-lock.json
generated
90
api/package-lock.json
generated
@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@ -24,6 +25,7 @@
|
||||
"dotenv": "^16.6.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@ -39,6 +41,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@ -3755,6 +3758,16 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/oauth": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
|
||||
"integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@ -3765,6 +3778,18 @@
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-google-oauth20": {
|
||||
"version": "2.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz",
|
||||
"integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*",
|
||||
"@types/passport-oauth2": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
@ -3776,6 +3801,18 @@
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-oauth2": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||
"integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/oauth": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
@ -5041,6 +5078,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64url": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
|
||||
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.23",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||
@ -9139,6 +9185,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -9354,6 +9406,18 @@
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-google-oauth20": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-oauth2": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
@ -9364,6 +9428,26 @@
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-oauth2": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
|
||||
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64url": "3.x.x",
|
||||
"oauth": "0.10.x",
|
||||
"passport-strategy": "1.x.x",
|
||||
"uid2": "0.0.x",
|
||||
"utils-merge": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
@ -11175,6 +11259,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/uid2": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
|
||||
@ -28,21 +28,22 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^12.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^12.0.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.6.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1"
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@ -53,10 +54,10 @@
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"tsx": "^4.20.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@ -71,6 +72,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
|
||||
@ -28,6 +28,10 @@ model PlatformUser {
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
googleId String? @unique
|
||||
githubId String? @unique
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import passport from 'passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { clearAuthCookies, readRefreshCookie, setAuthCookies } from './cookies';
|
||||
import type { AuthUser } from './auth.types';
|
||||
import { readOAuthState, signOAuthState } from './oauth-state';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ -23,6 +30,43 @@ export class AuthController {
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
googleStart(
|
||||
@Query('tenant') tenant: string | undefined,
|
||||
@Query('next') next: string | undefined,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
if (!this.auth.googleConfigured()) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_off'));
|
||||
}
|
||||
const kind = tenant ? 'tenant' : 'platform';
|
||||
const state = signOAuthState(
|
||||
{ kind, slug: tenant, next },
|
||||
this.config.getOrThrow<string>('JWT_SECRET'),
|
||||
);
|
||||
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
||||
url.searchParams.set('client_id', this.config.getOrThrow<string>('GOOGLE_CLIENT_ID'));
|
||||
url.searchParams.set('redirect_uri', this.config.getOrThrow<string>('GOOGLE_CALLBACK_URL'));
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'openid email profile');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('prompt', 'select_account');
|
||||
return response.redirect(url.toString());
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
googleCallback(@Req() request: Request, @Res() response: Response) {
|
||||
const rawState = String(request.query.state ?? '');
|
||||
const tenant = this.slugFromState(rawState);
|
||||
if (request.query.error) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
|
||||
passport.authenticate('google', { session: false }, (error: unknown, identity: GoogleIdentity | false) => {
|
||||
void this.finishGoogle(request, response, rawState, tenant, error, identity);
|
||||
})(request, response);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(200)
|
||||
async refresh(@Req() request: Request, @Res({ passthrough: true }) response: Response) {
|
||||
@ -49,4 +93,40 @@ export class AuthController {
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.profile(user);
|
||||
}
|
||||
|
||||
private async finishGoogle(
|
||||
request: Request,
|
||||
response: Response,
|
||||
rawState: string,
|
||||
tenant: string | undefined,
|
||||
error: unknown,
|
||||
identity: GoogleIdentity | false,
|
||||
) {
|
||||
try {
|
||||
if (error || !identity) {
|
||||
return response.redirect(this.failUrl(tenant, 'google_denied'));
|
||||
}
|
||||
const state = readOAuthState(rawState, this.config.getOrThrow<string>('JWT_SECRET'));
|
||||
const { session, redirect } = await this.auth.loginWithGoogle(identity, state, request.ip);
|
||||
setAuthCookies(response, session.accessToken, session.refreshToken);
|
||||
return response.redirect(redirect);
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : '';
|
||||
const code = message.includes('não tem acesso') ? 'google_unlinked' : 'google_denied';
|
||||
return response.redirect(this.failUrl(tenant, code));
|
||||
}
|
||||
}
|
||||
|
||||
private slugFromState(raw: string) {
|
||||
try {
|
||||
return readOAuthState(raw, this.config.getOrThrow<string>('JWT_SECRET')).slug;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private failUrl(tenant: string | undefined, code: string) {
|
||||
const path = tenant ? `/${tenant}/login` : '/admin/login';
|
||||
return `${this.auth.webOrigin()}${path}?error=${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -18,7 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, JwtStrategy, GoogleStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -15,7 +15,9 @@ import { TenantConnectionService } from '../tenancy/tenant-connection.service';
|
||||
import { normalizeSlug } from '../tenancy/tenant-url';
|
||||
import type { AuthUser, JwtPayload } from './auth.types';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import type { GoogleIdentity } from './strategies/google.strategy';
|
||||
import { permissionsFor, ROLE_LABEL } from './roles';
|
||||
import { type OAuthState, safeNext } from './oauth-state';
|
||||
|
||||
const LOGIN_WINDOW_SECONDS = 15 * 60;
|
||||
const LOGIN_MAX_ATTEMPTS = 10;
|
||||
@ -41,6 +43,25 @@ export class AuthService {
|
||||
return this.loginTenant(normalizeSlug(slug), dto.email, dto.password, ip);
|
||||
}
|
||||
|
||||
googleConfigured() {
|
||||
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
|
||||
}
|
||||
|
||||
webOrigin() {
|
||||
return this.config.get<string>('WEB_ORIGIN') ?? 'http://localhost:3000';
|
||||
}
|
||||
|
||||
async loginWithGoogle(identity: GoogleIdentity, state: OAuthState, ip?: string) {
|
||||
await this.assertRateLimit(identity.email, ip);
|
||||
if (state.kind === 'platform') {
|
||||
const session = await this.loginPlatformGoogle(identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, '/admin')}` };
|
||||
}
|
||||
const slug = normalizeSlug(state.slug ?? '');
|
||||
const session = await this.loginTenantGoogle(slug, identity, ip);
|
||||
return { session, redirect: `${this.webOrigin()}${safeNext(state.next, `/${slug}`)}` };
|
||||
}
|
||||
|
||||
async refresh(rawToken: string | undefined) {
|
||||
if (!rawToken) {
|
||||
throw new UnauthorizedException();
|
||||
@ -152,6 +173,74 @@ export class AuthService {
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async loginPlatformGoogle(identity: GoogleIdentity, ip?: string) {
|
||||
const user =
|
||||
(await this.platform.platformUser.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await this.platform.platformUser.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Este e-mail Google não tem acesso à plataforma.');
|
||||
}
|
||||
if (!user.googleId) {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
await this.platform.platformUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await this.platform.platformAuditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'PlatformUser', entityId: user.id },
|
||||
});
|
||||
return this.issuePlatformSession(user.id, user.email, user.name);
|
||||
}
|
||||
|
||||
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
|
||||
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
|
||||
if (!tenant || tenant.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('Credenciais inválidas.');
|
||||
}
|
||||
const db = this.tenants.getByDatabase(tenant.database);
|
||||
let user =
|
||||
(await db.user.findUnique({ where: { googleId: identity.googleId } })) ??
|
||||
(await db.user.findUnique({ where: { email: identity.email } }));
|
||||
if (!user) {
|
||||
const issuedPassword = `Sinka-${randomBytes(3).toString('hex')}`;
|
||||
user = await db.user.create({
|
||||
data: {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
googleId: identity.googleId,
|
||||
passwordHash: await bcrypt.hash(issuedPassword, 10),
|
||||
issuedPassword,
|
||||
role: 'OPERATOR',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: 'user.create',
|
||||
ip,
|
||||
entity: 'User',
|
||||
entityId: user.id,
|
||||
metadata: { via: 'google' },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { googleId: user.googleId ?? identity.googleId, lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
await db.auditLog.create({
|
||||
data: { userId: user.id, action: 'login.google', ip, entity: 'User', entityId: user.id },
|
||||
});
|
||||
return this.issueTenantSession(tenant.id, tenant.slug, user.id, user.email, user.name, user.role);
|
||||
}
|
||||
|
||||
private async issuePlatformSession(id: string, email: string, name: string) {
|
||||
const payload: JwtPayload = {
|
||||
sub: id,
|
||||
|
||||
21
api/src/auth/oauth-state.spec.ts
Normal file
21
api/src/auth/oauth-state.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { readOAuthState, safeNext, signOAuthState } from './oauth-state';
|
||||
|
||||
describe('oauth state', () => {
|
||||
const secret = 'test-secret';
|
||||
|
||||
it('round-trips a signed tenant state', () => {
|
||||
const token = signOAuthState({ kind: 'tenant', slug: 'demo', next: '/demo' }, secret);
|
||||
expect(readOAuthState(token, secret)).toEqual({ kind: 'tenant', slug: 'demo', next: '/demo' });
|
||||
});
|
||||
|
||||
it('rejects a tampered state', () => {
|
||||
const token = signOAuthState({ kind: 'platform' }, secret);
|
||||
expect(() => readOAuthState(`${token}x`, secret)).toThrow();
|
||||
});
|
||||
|
||||
it('blocks open redirects', () => {
|
||||
expect(safeNext('https://evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('//evil.test', '/admin')).toBe('/admin');
|
||||
expect(safeNext('/admin/clientes', '/admin')).toBe('/admin/clientes');
|
||||
});
|
||||
});
|
||||
41
api/src/auth/oauth-state.ts
Normal file
41
api/src/auth/oauth-state.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type OAuthState = {
|
||||
kind: 'platform' | 'tenant';
|
||||
slug?: string;
|
||||
next?: string;
|
||||
};
|
||||
|
||||
export function signOAuthState(payload: OAuthState, secret: string): string {
|
||||
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + 10 * 60 * 1000 })).toString('base64url');
|
||||
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function readOAuthState(raw: string | undefined, secret: string): OAuthState {
|
||||
if (!raw || !raw.includes('.')) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const [body, sig] = raw.split('.');
|
||||
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('state');
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(body, 'base64url').toString()) as OAuthState & { exp?: number };
|
||||
if (!parsed.exp || parsed.exp < Date.now()) {
|
||||
throw new Error('state');
|
||||
}
|
||||
if (parsed.kind !== 'platform' && parsed.kind !== 'tenant') {
|
||||
throw new Error('state');
|
||||
}
|
||||
return { kind: parsed.kind, slug: parsed.slug, next: parsed.next };
|
||||
}
|
||||
|
||||
export function safeNext(raw: string | undefined, fallback: string): string {
|
||||
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.includes('://')) {
|
||||
return fallback;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
34
api/src/auth/strategies/google.strategy.ts
Normal file
34
api/src/auth/strategies/google.strategy.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, type Profile } from 'passport-google-oauth20';
|
||||
|
||||
export type GoogleIdentity = {
|
||||
googleId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
clientID: config.get<string>('GOOGLE_CLIENT_ID') || 'not-configured',
|
||||
clientSecret: config.get<string>('GOOGLE_CLIENT_SECRET') || 'not-configured',
|
||||
callbackURL: config.get<string>('GOOGLE_CALLBACK_URL') || 'http://localhost:3000/api/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
validate(_accessToken: string, _refreshToken: string, profile: Profile): GoogleIdentity {
|
||||
const email = profile.emails?.[0]?.value?.toLowerCase();
|
||||
if (!email || !profile.id) {
|
||||
throw new UnauthorizedException('Conta Google sem e-mail.');
|
||||
}
|
||||
return {
|
||||
googleId: profile.id,
|
||||
email,
|
||||
name: profile.displayName?.trim() || email,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user