Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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