Skip to content

Commit

Permalink
Add @ConditionalScopes, closes #119
Browse files Browse the repository at this point in the history
  • Loading branch information
ferrerojosh committed Oct 18, 2024
1 parent 8de426d commit 3399c11
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 50 deletions.
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ export class ProductController {

@Post()
@Scopes('Create')
@ConditionalScopes((request, token) => {
if (token.hasRealmRole('sysadmin')) {
return ['Overwrite'];
}
return [];
})
async create(@Body() product: Product) {
return await this.service.create(product);
}
Expand All @@ -213,15 +219,17 @@ export class ProductController {

Here is the decorators you can use in your controllers.

| Decorator | Description |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| @KeycloakUser | Retrieves the current Keycloak logged-in user. (must be per method, unless controller is request scoped.) |
| @AccessToken | Retrieves the access token used in the request |
| @EnforcerOptions | Keycloak enforcer options. |
| @Public | Allow any user to use the route. |
| @Resource | Keycloak application resource name. |
| @Scopes | Keycloak application scope name. |
| @Roles | Keycloak realm/application roles. |
| Decorator | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| @KeycloakUser | Retrieves the current Keycloak logged-in user. (must be per method, unless controller is request scoped.) |
| @AccessToken | Retrieves the access token used in the request |
| @ResolvedScopes | Retrieves the resolved scopes (used in @ConditionalScopes) |
| @EnforcerOptions | Keycloak enforcer options. |
| @Public | Allow any user to use the route. |
| @Resource | Keycloak application resource name. |
| @Scopes | Keycloak application scopes. |
| @ConditionalScopes | Conditional keycloak application scopes. |
| @Roles | Keycloak realm/application roles. |

## Multi tenant configuration

Expand Down
2 changes: 1 addition & 1 deletion example/src/config/keycloak-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
export class KeycloakConfigService implements KeycloakConnectOptionsFactory {
createKeycloakConnectOptions(): KeycloakConnectOptions {
return {
authServerUrl: 'http://localhost:8180',
authServerUrl: 'http://localhost:8080',
realm: 'nest-example',
clientId: 'nest-api',
secret: '05c1ff5e-f9ba-4622-98e3-c4c9d280546e',
Expand Down
30 changes: 25 additions & 5 deletions example/src/product/product/product.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,48 @@ import {
Controller,
Delete,
Get,
Logger,
Param,
Post,
Put,
} from '@nestjs/common';
import { Public, Resource, Roles, Scopes } from 'nest-keycloak-connect';
import {
ConditionalScopes,
Public,
ResolvedScopes,
Resource,
Roles,
Scopes,
} from 'nest-keycloak-connect';
import { Product } from './product';
import { ProductService } from './product.service';

@Controller('product')
@Resource(Product.name)
export class ProductController {
private readonly logger = new Logger(ProductController.name);
constructor(private service: ProductService) {}

@Get()
@Public()
@Scopes('View')
findAll() {
return this.service.findAll();
@ConditionalScopes((request, token) => {
if (token.hasRealmRole('basic')) {
return ['View'];
}
if (token.hasRealmRole('admin')) {
return ['View.All'];
}
return [];
})
findAll(@ResolvedScopes() scopes: string[]) {
if (scopes.includes('View.All')) {
return this.service.findAll();
}
return this.service.findByCode('1-00-1');
}

@Get(':code')
@Roles({ roles: ['realm:basic'] })
@Roles({ roles: ['realm:basic', 'realm:admin'] })
findByCode(@Param('code') code: string) {
return this.service.findByCode(code);
}
Expand Down
31 changes: 30 additions & 1 deletion src/decorators/scopes.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,38 @@
import { SetMetadata } from '@nestjs/common';
import * as KeycloakConnect from 'keycloak-connect';

export const META_SCOPES = 'scopes';

export const META_CONDITIONAL_SCOPES = 'conditional-scopes';

export type ConditionalScopeFn = (
request: any,
token: KeycloakConnect.Token,
) => string[];

/**
* Keycloak Authorization Scopes.
* Keycloak authorization scopes.
* @param scopes - scopes that are associated with the resource
*/
export const Scopes = (...scopes: string[]) => SetMetadata(META_SCOPES, scopes);

/**
* Keycloak authorization conditional scopes.
* @param scopes - scopes that are associated with the resource depending on the conditions
*/
export const ConditionalScopes = (resolver: ConditionalScopeFn) =>
SetMetadata(META_CONDITIONAL_SCOPES, resolver);

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { extractRequest } from '../util';

/**
* Retrieves the resolved scopes.
* @since 1.5.0
*/
export const ResolvedScopes = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const [req] = extractRequest(ctx);
return req.scopes;
},
);
5 changes: 5 additions & 0 deletions src/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException();
}

// Public route, no jwt sent
if (isPublic && isJwtEmpty) {
return true;
}

this.logger.verbose(`Validating jwt`, { jwt });

const keycloak = await useKeycloak(
Expand Down
92 changes: 58 additions & 34 deletions src/guards/resource.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import {
import { META_ENFORCER_OPTIONS } from '../decorators/enforcer-options.decorator';
import { META_PUBLIC } from '../decorators/public.decorator';
import { META_RESOURCE } from '../decorators/resource.decorator';
import { META_SCOPES } from '../decorators/scopes.decorator';
import {
ConditionalScopeFn,
META_CONDITIONAL_SCOPES,
META_SCOPES,
} from '../decorators/scopes.decorator';
import { KeycloakConnectConfig } from '../interface/keycloak-connect-options.interface';
import { KeycloakMultiTenantService } from '../services/keycloak-multitenant.service';
import { extractRequestAndAttachCookie, useKeycloak } from '../util';
Expand Down Expand Up @@ -46,8 +50,10 @@ export class ResourceGuard implements CanActivate {
META_RESOURCE,
context.getClass(),
);
const scopes = this.reflector.get<string[]>(
META_SCOPES,
const explicitScopes =
this.reflector.get<string[]>(META_SCOPES, context.getHandler()) ?? [];
const conditionalScopes = this.reflector.get<ConditionalScopeFn>(
META_CONDITIONAL_SCOPES,
context.getHandler(),
);
const isPublic = this.reflector.getAllAndOverride<boolean>(META_PUBLIC, [
Expand All @@ -61,9 +67,39 @@ export class ResourceGuard implements CanActivate {
);

// Default to permissive
const pem =
const policyEnforcementMode =
this.keycloakOpts.policyEnforcement || PolicyEnforcementMode.PERMISSIVE;
const shouldAllow = pem === PolicyEnforcementMode.PERMISSIVE;
const shouldAllow =
policyEnforcementMode === PolicyEnforcementMode.PERMISSIVE;

// Extract request/response
const cookieKey = this.keycloakOpts.cookieKey || KEYCLOAK_COOKIE_DEFAULT;
const [request, response] = extractRequestAndAttachCookie(
context,
cookieKey,
);

// if is not an HTTP request ignore this guard
if (!request) {
return true;
}

if (!request.user && isPublic) {
this.logger.verbose(`Route has no user, and is public, allowed`);
return true;
}

const keycloak = await useKeycloak(
request,
request.accessToken,
this.singleTenant,
this.multiTenant,
this.keycloakOpts,
);

const grant = await keycloak.grantManager.createGrant({
access_token: request.accessToken,
});

// No resource given, check policy enforcement mode
if (!resource) {
Expand All @@ -79,15 +115,26 @@ export class ResourceGuard implements CanActivate {
return shouldAllow;
}

// Build the required scopes
const conditionalScopesResult =
conditionalScopes != null || conditionalScopes != undefined
? conditionalScopes(request, grant.access_token)
: [];

const scopes = [...explicitScopes, ...conditionalScopesResult];

// Attach resolved scopes
request.scopes = scopes;

// No scopes given, check policy enforcement mode
if (!scopes) {
if (!scopes || scopes.length === 0) {
if (shouldAllow) {
this.logger.verbose(
`Route has no @Scope defined, request allowed due to policy enforcement`,
`Route has no @Scope/@ConditionalScopes defined, request allowed due to policy enforcement`,
);
} else {
this.logger.verbose(
`Route has no @Scope defined, request denied due to policy enforcement`,
`Route has no @Scope/@ConditionalScopes defined, request denied due to policy enforcement`,
);
}
return shouldAllow;
Expand All @@ -97,35 +144,12 @@ export class ResourceGuard implements CanActivate {
`Protecting resource [ ${resource} ] with scopes: [ ${scopes} ]`,
);

// Build permissions
const permissions = scopes.map((scope) => `${resource}:${scope}`);
// Extract request/response
const cookieKey = this.keycloakOpts.cookieKey || KEYCLOAK_COOKIE_DEFAULT;
const [request, response] = extractRequestAndAttachCookie(
context,
cookieKey,
);

// if is not an HTTP request ignore this guard
if (!request) {
return true;
}

if (!request.user && isPublic) {
this.logger.verbose(`Route has no user, and is public, allowed`);
return true;
}

const user = request.user?.preferred_username ?? 'user';

const enforcerFn = createEnforcerContext(request, response, enforcerOpts);
const keycloak = await useKeycloak(
request,
request.accessTokenJWT,
this.singleTenant,
this.multiTenant,
this.keycloakOpts,
);

// Build permissions
const permissions = scopes.map((scope) => `${resource}:${scope}`);
const isAllowed = await enforcerFn(keycloak, permissions);

// If statement for verbose logging only
Expand Down

0 comments on commit 3399c11

Please sign in to comment.