Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,21 @@ AUTH_REGISTER_THROTTLE_TTL_MS=60000
AUTH_REGISTER_THROTTLE_LIMIT=5
AUTH_FORGOT_THROTTLE_TTL_MS=60000
AUTH_FORGOT_THROTTLE_LIMIT=5
AUTH_TOKEN_THROTTLE_TTL_MS=60000
AUTH_TOKEN_THROTTLE_LIMIT=10

# ─── CORS / Frontend ────────────────────────────────────────────────────────────
# Origin allowed by CORS (frontend URL). Must be a valid URI.
ALLOWED_ORIGIN=http://localhost:5173
# Base URL used to construct password reset links sent via email.
FRONTEND_URL=http://localhost:5173

# ─── OAuth M2M ──────────────────────────────────────────────────────────────────
# Guards the POST /oauth-clients admin endpoint. Required in production (min 32
# characters). In development/test, leaving this blank or unset disables the
# endpoint entirely (the guard will always return 401). Set a value to use it.
INTERNAL_API_KEY=your-internal-api-key-minimum-32-chars

# ─── UEX Sync ───────────────────────────────────────────────────────────────────
UEX_SYNC_ENABLED=true
UEX_CATEGORIES_SYNC_ENABLED=true
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { LocationsModule } from './modules/locations/locations.module';
import { UserInventoryModule } from './modules/user-inventory/user-inventory.module';
import { OrgInventoryModule } from './modules/org-inventory/org-inventory.module';
import { HealthModule } from './health/health.module';
import { OauthClientsModule } from './modules/oauth-clients/oauth-clients.module';

const isTest =
process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
Expand Down Expand Up @@ -148,6 +149,7 @@ if (!isTest) {
UserInventoryModule,
OrgInventoryModule,
HealthModule,
OauthClientsModule,
],
controllers: [AppController],
providers: [
Expand Down
10 changes: 10 additions & 0 deletions backend/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,14 @@ export const envValidationSchema = Joi.object({

// Token cleanup scheduler (optional — defaults to 3 AM daily)
REFRESH_TOKEN_CLEANUP_CRON: Joi.string().default(DEFAULT_CLEANUP_CRON),

// OAuth M2M — internal API key for the /oauth-clients admin endpoint.
// Required in production; optional in development/test.
INTERNAL_API_KEY: Joi.string()
.min(32)
.when('NODE_ENV', {
is: 'production',
then: Joi.required(),
otherwise: Joi.string().min(32).optional().allow(''),
}),
});
6 changes: 6 additions & 0 deletions backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { UexOutpost } from './modules/uex/entities/uex-outpost.entity';
import { UexPoi } from './modules/uex/entities/uex-poi.entity';
import { UexSyncState } from './modules/uex-sync/uex-sync-state.entity';
import { UexSyncConfig } from './modules/uex-sync/uex-sync-config.entity';
import { OauthClient } from './modules/oauth-clients/oauth-client.entity';

import { CreateUsersTable1716956654528 } from './migrations/1716956654528-CreateUsersTable';
import { CreateOrganizationsRolesAndJunctionTable1730841000000 } from './migrations/1730841000000-CreateOrganizationsRolesAndJunctionTable';
Expand All @@ -49,6 +50,7 @@ import { CreateOrgInventoryItemsTable1764964935270 } from './migrations/17649649
import { AddUserInventoryUniqueIndex1765035000000 } from './migrations/1765035000000-AddUserInventoryUniqueIndex';
import { AddTokenCleanupIndexes1765038000000 } from './migrations/1765038000000-AddTokenCleanupIndexes';
import { DropRefreshTokensTable1777409770542 } from './migrations/1777409770542-DropRefreshTokensTable';
import { CreateOauthClientsTable1777647814618 } from './migrations/1777647814618-CreateOauthClientsTable';

export const AppDataSource = new DataSource({
type: 'postgres',
Expand Down Expand Up @@ -81,6 +83,7 @@ export const AppDataSource = new DataSource({
UexPoi,
UexSyncState,
UexSyncConfig,
OauthClient,
],
migrations: [
// Core user/org/auth setup
Expand Down Expand Up @@ -120,6 +123,9 @@ export const AppDataSource = new DataSource({

// Refresh tokens moved to Redis — drop the DB table
DropRefreshTokensTable1777409770542,

// OAuth 2.0 client credentials
CreateOauthClientsTable1777647814618,
],
synchronize: false,
});
Expand Down
9 changes: 9 additions & 0 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ async function bootstrap() {
},
'access-token',
)
.addApiKey(
{
type: 'apiKey',
in: 'header',
name: 'x-internal-api-key',
description: 'Internal API key for admin/automation endpoints',
},
'internal-api-key',
)
.build();

const document = SwaggerModule.createDocument(app, config);
Expand Down
24 changes: 24 additions & 0 deletions backend/src/migrations/1777647814618-CreateOauthClientsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateOauthClientsTable1777647814618
implements MigrationInterface
{
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "oauth_clients" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"clientId" VARCHAR NOT NULL,
"clientSecretHash" VARCHAR NOT NULL,
"scopes" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_oauth_clients" PRIMARY KEY ("id"),
CONSTRAINT "UQ_oauth_clients_clientId" UNIQUE ("clientId")
)
`);
Comment thread
GitAddRemote marked this conversation as resolved.
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "oauth_clients"`);
}
}
5 changes: 5 additions & 0 deletions backend/src/modules/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthenticatedRequest } from './interfaces/authenticated-request.interface';
import { RefreshTokenAuthGuard } from './refresh-token-auth.guard';
import { OauthClientsService } from '../oauth-clients/oauth-clients.service';

describe('AuthController - Password Reset', () => {
let controller: AuthController;
Expand Down Expand Up @@ -40,6 +41,10 @@ describe('AuthController - Password Reset', () => {
},
},
RefreshTokenAuthGuard,
{
provide: OauthClientsService,
useValue: { validateClient: jest.fn(), register: jest.fn() },
},
],
}).compile();

Expand Down
88 changes: 88 additions & 0 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
UseGuards,
Request,
Body,
Headers,
Res,
HttpCode,
HttpStatus,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -29,9 +31,11 @@ import {
ForgotPasswordDto,
ResetPasswordDto,
} from './dto/password-reset.dto';
import { TokenRequestDto } from './dto/token-request.dto';
import { AuthenticatedRequest } from './interfaces/authenticated-request.interface';
import { RefreshTokenRequest } from './interfaces/refresh-token-request.interface';
import { ValidatedUser } from './interfaces/validated-user.interface';
import { OauthClientsService } from '../oauth-clients/oauth-clients.service';

// Parse throttle config once at module load time.
// Number() handles numeric strings and NaN from non-numeric input; the
Expand Down Expand Up @@ -63,13 +67,19 @@ const FORGOT_LIMIT = toThrottleInt(
process.env['AUTH_FORGOT_THROTTLE_LIMIT'],
5,
);
const TOKEN_TTL = toThrottleInt(
process.env['AUTH_TOKEN_THROTTLE_TTL_MS'],
60_000,
);
const TOKEN_LIMIT = toThrottleInt(process.env['AUTH_TOKEN_THROTTLE_LIMIT'], 10);

@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(
private authService: AuthService,
private configService: ConfigService,
private oauthClientsService: OauthClientsService,
) {}

private cookieOptions(maxAge: number) {
Expand All @@ -82,6 +92,84 @@ export class AuthController {
};
}

@ApiOperation({
summary: 'OAuth 2.0 Client Credentials token endpoint (M2M)',
description:
'Accepts JSON body or application/x-www-form-urlencoded. ' +
'Client credentials may also be supplied via Authorization: Basic <base64(client_id:client_secret)> ' +
'with grant_type in the body.',
})
@ApiBody({ type: TokenRequestDto })
@ApiResponse({ status: 200, description: 'Access token issued' })
@ApiResponse({ status: 401, description: 'Invalid client credentials' })
@Throttle({ default: { ttl: TOKEN_TTL, limit: TOKEN_LIMIT } })
@HttpCode(HttpStatus.OK)
@Post('token')
async token(
@Body() dto: TokenRequestDto,
@Headers('authorization') rawAuthHeader?: string | string[],
) {
// Normalize: Express can produce string | string[] for a header value.
const authHeader = Array.isArray(rawAuthHeader)
? rawAuthHeader[0]
: rawAuthHeader;

// RFC 6749 §2.3.1: client may authenticate via Authorization: Basic
// base64(client_id:client_secret) instead of body parameters.
let clientId = dto.client_id;
let clientSecret = dto.client_secret;

if (authHeader?.match(/^basic /i)) {
const encoded = authHeader.slice(authHeader.indexOf(' ') + 1).trim();
const decoded = Buffer.from(encoded, 'base64').toString();
const colon = decoded.indexOf(':');
if (colon < 1) {
throw new UnauthorizedException('Malformed Basic authorization header');
}
clientId = decoded.substring(0, colon);
clientSecret = decoded.substring(colon + 1);
Comment thread
GitAddRemote marked this conversation as resolved.
}

if (!clientId || !clientSecret) {
throw new UnauthorizedException(
'Client credentials required: supply client_id and client_secret in the body or via Authorization: Basic',
);
}

const client = await this.oauthClientsService.validateClient(
clientId,
clientSecret,
);

// An absent scope parameter grants the full registered set (RFC 6749 §4.4.2).
// When a scope parameter is present, every requested scope must be in the
// client's registered set — silently dropping unknown scopes would let callers
// mint tokens without realising their scope request was partially ignored.
const parsedScopes = dto.scope
? dto.scope.split(' ').filter(Boolean)
: null;
// Treat a whitespace-only scope string (e.g. scope="+") as absent so it
// falls back to the client's full registered set rather than minting an
// empty-scope token.
const requestedScopes =
parsedScopes && parsedScopes.length > 0 ? parsedScopes : null;

if (requestedScopes) {
const unauthorized = requestedScopes.filter(
(s) => !client.scopes.includes(s),
);
if (unauthorized.length > 0) {
throw new UnauthorizedException(
'Requested scope is not permitted for this client',
);
}
}

const grantedScopes = requestedScopes ?? client.scopes;

return this.authService.issueClientToken(client, grantedScopes);
}

@ApiOperation({ summary: 'Login user' })
@ApiBody({
schema: {
Expand Down
8 changes: 7 additions & 1 deletion backend/src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ import { TokenCleanupService } from './token-cleanup.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { OauthClientsModule } from '../oauth-clients/oauth-clients.module';
import { PasswordReset } from './password-reset.entity';
import { RefreshTokenAuthGuard } from './refresh-token-auth.guard';
import { ClientAuthGuard } from './guards/client-auth.guard';
import { ScopesGuard } from './guards/scopes.guard';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { createClient } from 'redis';

@Module({
imports: [
UsersModule,
OauthClientsModule,
PassportModule,
TypeOrmModule.forFeature([PasswordReset]),
JwtModule.registerAsync({
Expand All @@ -34,6 +38,8 @@ import { createClient } from 'redis';
LocalStrategy,
JwtStrategy,
RefreshTokenAuthGuard,
ClientAuthGuard,
ScopesGuard,
{
provide: REDIS_CLIENT,
inject: [ConfigService],
Expand All @@ -59,6 +65,6 @@ import { createClient } from 'redis';
},
},
],
exports: [AuthService],
exports: [AuthService, ClientAuthGuard, ScopesGuard],
})
export class AuthModule {}
37 changes: 37 additions & 0 deletions backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ValidatedUser } from './interfaces/validated-user.interface';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import { ClientJwtPayload } from './interfaces/client-jwt-payload.interface';
import { OauthClient } from '../oauth-clients/oauth-client.entity';

export const REDIS_CLIENT = Symbol('REDIS_CLIENT');

Expand Down Expand Up @@ -427,6 +429,41 @@ export class AuthService {
return { message: 'Password has been reset successfully' };
}

async issueClientToken(
client: OauthClient,
grantedScopes: string[] = client.scopes,
): Promise<{
access_token: string;
token_type: 'Bearer';
expires_in: number;
}> {
const CLIENT_TTL_SECONDS = 3600;
const jti = crypto.randomUUID();
const payload: ClientJwtPayload = {
sub: client.clientId,
type: 'client',
scopes: grantedScopes,
jti,
};
const access_token = this.jwtService.sign(payload, {
expiresIn: CLIENT_TTL_SECONDS,
});
// Track live client tokens so a future admin revoke endpoint can enumerate
// and invalidate them per client. Key: client-token:{clientId}:{jti}.
// Revocation itself uses the existing blacklist:{jti} mechanism checked by
// isAccessTokenBlacklisted.
await this.authSet(
`client-token:${client.clientId}:${jti}`,
'1',
CLIENT_TTL_SECONDS * 1000,
);
return {
access_token,
token_type: 'Bearer',
expires_in: CLIENT_TTL_SECONDS,
};
}

async changePassword(
userId: number,
currentPassword: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';

export const REQUIRE_SCOPES_KEY = 'requiredScopes';

export const RequireScopes = (...scopes: string[]) =>
SetMetadata(REQUIRE_SCOPES_KEY, scopes);
28 changes: 28 additions & 0 deletions backend/src/modules/auth/dto/token-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, Equals } from 'class-validator';

export class TokenRequestDto {
@ApiProperty({ example: 'client_credentials' })
@IsString()
@Equals('client_credentials', {
message: 'grant_type must be "client_credentials"',
})
grant_type!: string;

@ApiProperty({ example: 'station-bot', required: false })
@IsOptional()
@IsString()
@IsNotEmpty()
client_id?: string;

@ApiProperty({ example: 'super-secret-value', required: false })
@IsOptional()
@IsString()
@IsNotEmpty()
client_secret?: string;

@ApiProperty({ example: 'bot:api', required: false })
@IsOptional()
@IsString()
scope?: string;
}
Loading
Loading