@nathapp/nestjs-oauth
v1.1.0
Published
Reusable OAuth2 Authorization Server + OpenID Connect Provider for NestJS
Readme
@nathapp/nestjs-oauth
A reusable, ORM-agnostic OAuth2 Authorization Server + OpenID Connect Provider for the @nathapp/nestjs-platform monorepo.
Ported and hardened from the proven logic in repos/nestjs-auth-api, restructured to platform conventions: interface-first core, separate Prisma adapter, DI-token seams, tenant-scoped data, and RS256/ES256 signing via database-backed signing keys (PrismaSigningKeyProvider).
Core Features
- OAuth2 Authorization Code flow with PKCE (S256)
- OpenID Connect Provider with id_token issuance and JWKS discovery
- Refresh token rotation with reuse detection
- Client Credentials flow for service-to-service auth
- Token introspection & revocation (RFC 7662 / RFC 7009)
- Token cleanup service for consumer-scheduled expiry purging
- Rate limiting via
@nathapp/nestjs-throttler(opt-in) - Multi-tenant isolation via
TenantContext(column-based) - Admin CRUD endpoints for OAuth clients (guard-agnostic)
- Spec-compliant redirect flows (real HTTP 302 responses)
Module wiring (Phase 2)
import { OAuthModule } from '@nathapp/nestjs-oauth';
import { OAuthPrismaModule } from '@nathapp/nestjs-oauth-prisma';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
EventEmitterModule.forRoot(), // required — OAuth services emit lifecycle events
TenantModule.register({ /* provides TENANT_CONTEXT */ }),
OAuthPrismaModule, // provides OAUTH_*_REPOSITORY tokens
OAuthModule.register({}), // client + scope-validation services + admin controller
],
})
export class AppModule {}The admin controller (oauth/admin/clients) is guard-agnostic — protect it with
your own auth guard, or disable it with OAuthModule.register({ registerAdminController: false }).
A created confidential client returns its plaintext secret once, in the create response.
Introspection & Revocation
The library ships POST /oauth/introspect (RFC 7662) and POST /oauth/revoke (RFC 7009) endpoints. Both require client authentication (HTTP Basic or body credentials) and are enabled by default. Disable via:
OAuthModule.register({
registerIntrospectionController: false,
registerRevocationController: false,
});Override the default services via DI tokens:
{ provide: OAUTH_INTROSPECTION_SERVICE, useClass: MyIntrospectionService }
{ provide: OAUTH_REVOCATION_SERVICE, useClass: MyRevocationService }Caveat: Revoking an access token does not automatically revoke its associated refresh token (access tokens carry no back-link to refresh tokens per the data model). If you need linked revocation, override DefaultRevocationService.
Token Cleanup
TokenCleanupService (injectable via OAUTH_TOKEN_CLEANUP_SERVICE) exposes cleanupExpired(now?: Date) which purges expired authorization codes, access tokens, and refresh tokens. Scheduling is the consumer's responsibility — no @nestjs/schedule dependency is pulled in.
import { Cron } from '@nestjs/schedule';
@Injectable()
class MyScheduler {
constructor(
@Inject(OAUTH_TOKEN_CLEANUP_SERVICE) private readonly cleanup: TokenCleanupService,
) {}
@Cron('0 3 * * *')
async purgeTokens() {
const { codes, accessTokens, refreshTokens } = await this.cleanup.cleanupExpired();
this.logger.log(`Purged ${codes} codes, ${accessTokens} access, ${refreshTokens} refresh`);
}
}Rate Limiting
Sensitive endpoints (/oauth/token, /oauth/authorize, /oauth/introspect, /oauth/revoke) carry @Throttle decorators with conservative defaults. These are metadata only — they activate when the consumer registers ThrottlerModule and applies DefaultThrottlerGuard globally:
import { ThrottlerModule } from '@nathapp/nestjs-throttler';
import { APP_GUARD } from '@nestjs/core';
@Module({
imports: [ThrottlerModule.forRoot({ ttl: 60, limit: 100 })],
providers: [{ provide: APP_GUARD, useClass: DefaultThrottlerGuard }],
})Alternatively, apply your own rate limiting at the gateway/reverse-proxy level for these routes.
auth_time Claim
To populate the auth_time claim in the id_token, the consumer's CURRENT_USER_RESOLVER must return authTime alongside userId. If omitted, the claim is not included in the id_token.
{
provide: CURRENT_USER_RESOLVER,
useFactory: (authService: AuthService) => ({
resolve: async (req: any) => {
const session = await authService.getSession(req);
if (!session) return null;
return { userId: session.userId, authTime: session.authenticatedAt };
},
}),
inject: [AuthService],
}Security
Consent CSRF
The library enforces that granted scopes never exceed the client's registered set (server-side, via DefaultScopeValidationService), but it does not issue its own CSRF token for the consent POST. The consuming application MUST:
- CSRF-protect
POST /oauth/authorize/consent(e.g. session-bound CSRF token validated before the handler runs) - Ensure the consent UI submits only the scopes that were displayed to the user
