Skip to content
Open
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
6 changes: 5 additions & 1 deletion app/backend/cache/redis.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
this.client?.disconnect();
}

getClient(): Redis {
return this.client;
}

/**
* Retrieve and deserialise a cached value.
* Returns `null` on cache miss or if Redis is unavailable.
Expand Down Expand Up @@ -93,4 +97,4 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
return 0;
}
}
}
}
1 change: 1 addition & 0 deletions app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"dotenv": "^17.2.3",
"helmet": "^8.1.0",
"ioredis": "^5.9.2",
"jose": "^4",
"openai": "^6.33.0",
"pino": "^10.3.0",
"pino-http": "^11.0.0",
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar
import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor';
import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor';
import { SandboxModule } from './sandbox/sandbox.module';
import { AuthOidcModule } from './auth-oidc/auth-oidc.module';

@Module({
imports: [
Expand Down Expand Up @@ -114,6 +115,7 @@ import { SandboxModule } from './sandbox/sandbox.module';
EntityLinkingModule,
DeploymentMetadataModule,
SandboxModule,
AuthOidcModule,
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
15 changes: 15 additions & 0 deletions app/backend/src/auth-oidc/auth-oidc.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from '../prisma/prisma.module';
import { RedisService } from '../../cache/redis.service';
import { TokenController } from './token.controller';
import { TokenService } from './token.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';

@Module({
imports: [ConfigModule, PrismaModule],
controllers: [TokenController],
providers: [TokenService, JwtAuthGuard, RedisService],
exports: [TokenService, JwtAuthGuard],
})
export class AuthOidcModule {}
46 changes: 46 additions & 0 deletions app/backend/src/auth-oidc/dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';

export class TokenRequestDto {
@ApiProperty({
enum: ['client_credentials', 'refresh_token'],
description:
'OAuth grant type. This backend has no password login flow, so JWT issuance is based on existing API-key client credentials.',
})
@IsIn(['client_credentials', 'refresh_token'])
grant_type!: 'client_credentials' | 'refresh_token';

@ApiPropertyOptional({
description:
'Optional client identifier. API-key records are validated by client_secret.',
})
@IsOptional()
@IsString()
client_id?: string;

@ApiPropertyOptional({
description: 'Existing ChainForge API key used as the OAuth client secret.',
})
@IsOptional()
@IsString()
client_secret?: string;

@ApiPropertyOptional({
description: 'Refresh token, required when grant_type=refresh_token.',
})
@IsOptional()
@IsString()
refresh_token?: string;
}

export class TokenIntrospectionDto {
@ApiProperty({ description: 'JWT access or refresh token to introspect.' })
@IsString()
token!: string;
}

export class TokenRevocationDto {
@ApiProperty({ description: 'JWT access or refresh token to revoke.' })
@IsString()
token!: string;
}
67 changes: 67 additions & 0 deletions app/backend/src/auth-oidc/token.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { Request } from 'express';
import { Public } from '../common/decorators/public.decorator';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
TokenIntrospectionDto,
TokenRequestDto,
TokenRevocationDto,
} from './dto';
import { TokenService } from './token.service';

@ApiTags('OAuth')
@Controller('oauth')
export class TokenController {
constructor(private readonly tokenService: TokenService) {}

@Public()
@Post('token')
@ApiOperation({
summary: 'Issue or refresh OAuth-compatible JWTs',
description:
'Supports client_credentials using an existing ChainForge API key as client_secret, and refresh_token. No password login flow exists in this backend.',
})
@ApiBody({ type: TokenRequestDto })
@ApiOkResponse({ description: 'Token pair issued.' })
async token(@Body() dto: TokenRequestDto) {
if (dto.grant_type === 'client_credentials') {
return this.tokenService.issueForClientCredentials(dto.client_secret);
}
return this.tokenService.refresh(dto.refresh_token);
}

@Public()
@UseGuards(JwtAuthGuard)
@Get('userinfo')
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Return claims for the current JWT principal' })
userinfo(@Req() req: Request) {
return req.user;
}

@Public()
@Post('introspect')
@ApiOperation({
summary: 'Introspect a JWT using RFC 7662 active/inactive shape',
})
@ApiBody({ type: TokenIntrospectionDto })
introspect(@Body() dto: TokenIntrospectionDto) {
return this.tokenService.introspect(dto.token);
}

@Public()
@Post('revoke')
@ApiOperation({ summary: 'Revoke a JWT by adding its jti to Redis' })
@ApiBody({ type: TokenRevocationDto })
async revoke(@Body() dto: TokenRevocationDto) {
await this.tokenService.revoke(dto.token);
return { revoked: true };
}
}
Loading
Loading