Skip to content

Commit d6fe865

Browse files
committed
feat(uma): Add support for User-Managed Access
1 parent 23d52f4 commit d6fe865

File tree

8 files changed

+277
-61
lines changed

8 files changed

+277
-61
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@nestjs/common": "^7.1.3"
4242
},
4343
"dependencies": {
44+
"axios": "^0.19.2",
4445
"keycloak-admin": "^1.13.0",
4546
"openid-client": "^3.15.2",
4647
"reflect-metadata": "^0.1.13",

readme.md

+42-16
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,51 @@
1-
### Keycloak Admin Client for NestJs
1+
## Keycloak Admin Client for NestJs
22

33
Install using `npm i --save nestjs-keycloak-admin` or `yarn add nestjs-keycloak-admin`
44

55
Then on your app.module.ts
66

7-
```javascript
7+
```typescript
88
@Module({
9-
KeycloakAdminModule.registerAsync({
10-
imports: [ConfigModule],
11-
useFactory: async () => ({
12-
config: {
13-
baseUrl: 'https://relevantfruit.com/auth',
14-
realmName: 'relevant-fruit',
15-
jwtIssuer: 'https://relevantfruit.com/auth/realms/relevant-fruit'
16-
},
17-
credentials: {
18-
clientId: 'batman',
19-
clientSecret: 'batman-is-cool'
20-
}
9+
imports: [
10+
KeycloakAdminModule.registerAsync({
11+
imports: [ConfigModule],
12+
useFactory: async () => ({
13+
config: {
14+
baseUrl: 'https://relevantfruit.com/auth',
15+
realmName: 'relevant-fruit',
16+
jwtIssuer: 'https://relevantfruit.com/auth/realms/relevant-fruit'
17+
},
18+
credentials: {
19+
clientId: 'batman',
20+
clientSecret: 'batman-is-cool'
21+
}
22+
}),
23+
inject: [ConfigService]
2124
}),
22-
inject: [ConfigService]
23-
}),
25+
]
2426
})
2527
```
28+
29+
### UMA Support
30+
31+
By default nestjs-keycloak-admin supports User Managed Access for managing your resources.
32+
33+
```typescript
34+
class Organization() {
35+
constructor(private readonly adminProvider: KeycloakAdminService) {}
36+
37+
async findAll(): UMAResource[] {
38+
return this.adminProvider.resourceManager.findAll()
39+
}
40+
41+
async create(payload: payload): Promise<UMAResource> {
42+
const resource = new UMAResource(payload)
43+
.setOwner(1)
44+
.addScope('organization:create')
45+
.setType('organization')
46+
.setUri('/organization/123')
47+
48+
return this.adminProvider.create(resource)
49+
}
50+
}
51+
```

src/interfaces.ts

+16
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,19 @@ export interface KeycloakAdminOptions {
2828
config: KeycloakAdminConfig;
2929
credentials: Credentials;
3030
}
31+
32+
export interface UMAScopeOptions {
33+
name: string;
34+
id?: string;
35+
iconUri?: string;
36+
}
37+
38+
export interface UMAResourceOptions {
39+
name: string;
40+
id?: string;
41+
uri?: string;
42+
type?: string;
43+
iconUri?: string;
44+
owner?: string;
45+
scopes?: string[] | UMAScopeOptions[];
46+
}

src/lib/request-manager.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
2+
import { KeycloakAdminService } from '../service';
3+
4+
export class RequestManager {
5+
private requester: AxiosInstance;
6+
private readonly client: KeycloakAdminService;
7+
8+
constructor(client: KeycloakAdminService) {
9+
this.client = client;
10+
11+
const { baseUrl, realmName } = this.client.options.config;
12+
this.requester = Axios.create({
13+
baseURL: `${baseUrl}/auth/realms/${realmName}`,
14+
});
15+
16+
this.requester.interceptors.request.use(async (config) => {
17+
const tokenSet = await this.client.refreshGrant();
18+
config.headers.authorization = `Bearer ${tokenSet.access_token}`;
19+
return config;
20+
});
21+
}
22+
23+
async get<T>(...args: [string, (AxiosRequestConfig | undefined)?]) {
24+
return this.requester.get.apply<any, any, Promise<AxiosResponse<T>>>(
25+
null,
26+
args,
27+
);
28+
}
29+
30+
async post<T>(...args: [string, any?, (AxiosRequestConfig | undefined)?]) {
31+
return this.requester.post.apply<any, any, Promise<AxiosResponse<T>>>(
32+
null,
33+
args,
34+
);
35+
}
36+
37+
async put<T>(...args: [string, any?, (AxiosRequestConfig | undefined)?]) {
38+
return this.requester.put.apply<any, any, Promise<AxiosResponse<T>>>(
39+
null,
40+
args,
41+
);
42+
}
43+
44+
async delete<T>(...args: [string, (AxiosRequestConfig | undefined)?]) {
45+
return this.requester.delete.apply<any, any, Promise<AxiosResponse<T>>>(
46+
null,
47+
args,
48+
);
49+
}
50+
}

src/lib/resource-manager.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { UMAResourceOptions } from '../interfaces';
2+
import { KeycloakAdminService } from '../service';
3+
import { UMAResource } from '../uma/resource';
4+
import { RequestManager } from './request-manager';
5+
6+
export class ResourceManager {
7+
private readonly requestManager: RequestManager;
8+
9+
constructor(client: KeycloakAdminService) {
10+
this.requestManager = new RequestManager(client);
11+
}
12+
13+
async create(resource: UMAResource): Promise<UMAResource> {
14+
const { data } = await this.requestManager.post<UMAResourceOptions>(
15+
'/authz/protection/resource_set',
16+
resource.toJson(),
17+
);
18+
19+
if (data.id) {
20+
resource.setId(data.id);
21+
}
22+
23+
return resource;
24+
}
25+
26+
async update(resource: UMAResource): Promise<UMAResource> {
27+
const { id } = resource.toJson();
28+
29+
if (!id) throw new Error(`Id is missing from resource`);
30+
31+
await this.requestManager.put<any>(
32+
`/authz/protection/resource_set/${id}`,
33+
resource.toJson(),
34+
);
35+
return resource;
36+
}
37+
38+
async delete(resource: UMAResource): Promise<void> {
39+
const { id } = resource.toJson();
40+
41+
if (!id) throw new Error(`Id is missing from resource`);
42+
43+
await this.requestManager.delete<any>(
44+
`/authz/protection/resource_set/${id}`,
45+
);
46+
}
47+
48+
async findById(id: string): Promise<UMAResource | null> {
49+
const { data } = await this.requestManager.get<UMAResourceOptions>(
50+
`/authz/protection/resource_set/${id}`,
51+
);
52+
53+
return data && new UMAResource(data);
54+
}
55+
56+
async findAll(deep: Boolean = false): Promise<UMAResource[] | any> {
57+
const { data } = await this.requestManager.get<string[]>(
58+
`/authz/protection/resource_set`,
59+
);
60+
61+
if (deep) {
62+
return data;
63+
}
64+
65+
return Promise.all(data.map((id) => this.findById(id)));
66+
}
67+
}

src/service.ts

+18-45
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { Logger } from '@nestjs/common';
22
import AdminClient from 'keycloak-admin';
3-
import { Client, GrantBody, Issuer, TokenSet } from 'openid-client';
3+
import { Client, Issuer, TokenSet } from 'openid-client';
44
import { KeycloakAdminOptions } from './interfaces';
5+
import { ResourceManager } from './lib/resource-manager';
56

67
export class KeycloakAdminService {
78
private logger = new Logger(KeycloakAdminService.name);
89

9-
private options: KeycloakAdminOptions;
10-
private client: AdminClient;
11-
private tokenSet: TokenSet | null | undefined;
12-
private issuerClient: Client | null | undefined;
13-
private connectionConfig: GrantBody & any;
10+
public readonly options: KeycloakAdminOptions;
11+
private tokenSet?: TokenSet;
12+
private issuerClient?: Client;
13+
public resourceManager: ResourceManager;
14+
public client: AdminClient;
1415

1516
constructor(options: KeycloakAdminOptions) {
1617
this.options = options;
1718
this.client = new AdminClient(options.config);
19+
this.resourceManager = new ResourceManager(this);
1820
this.initConnection();
1921
}
2022

@@ -27,9 +29,6 @@ export class KeycloakAdminService {
2729
grantType: 'client_credentials',
2830
} as any);
2931

30-
if (!this.options.config.baseUrl) {
31-
throw new Error(`Base url is missing from options.`);
32-
}
3332
const keycloakIssuer = await Issuer.discover(this.options.config.jwtIssuer);
3433

3534
this.issuerClient = new keycloakIssuer.Client({
@@ -46,46 +45,20 @@ export class KeycloakAdminService {
4645
this.logger.log(
4746
`Initial token expires at ${new Date(this.tokenSet.expires_at!)}`,
4847
);
49-
50-
this.initRefresh();
5148
}
5249

53-
async initRefresh() {
54-
// Periodically using refresh_token grant flow to get new access token here
55-
// TODO: it will be better to check for token expiration instead of interval check
56-
setInterval(async () => {
57-
const tokenSet = this.tokenSet;
58-
59-
if (!tokenSet || !tokenSet?.refresh_token) {
60-
return this.logger.warn(
61-
'Refresh token is missing. Refresh doesnt work.',
62-
);
63-
}
50+
async refreshGrant(): Promise<TokenSet> {
51+
if (this.tokenSet && !this.tokenSet.expired()) {
52+
return this.tokenSet;
53+
}
6454

65-
if (!tokenSet.expired()) {
66-
return this.logger.verbose(`Omitting refreshing of Keycloak token.`);
67-
}
55+
this.logger.verbose(`Grant token expired, refreshing.`);
6856

69-
try {
70-
this.tokenSet = await this.issuerClient?.refresh(
71-
tokenSet.refresh_token,
72-
);
73-
this.logger.log('Successfully refreshed token');
74-
} catch (e) {
75-
if (e.name === 'TimeoutError' || e?.error === 'invalid_grant') {
76-
this.tokenSet = await this.issuerClient?.grant(this.connectionConfig);
77-
} else {
78-
this.logger.error(e);
79-
throw e;
80-
}
81-
}
82-
if (this.tokenSet?.access_token) {
83-
this.client.setAccessToken(this.tokenSet.access_token);
84-
}
85-
}, 58 * 1000); // 58 seconds
86-
}
57+
this.tokenSet = await this.issuerClient?.refresh(
58+
this.tokenSet!.refresh_token!,
59+
);
8760

88-
getClient() {
89-
return this.client;
61+
this.client.setAccessToken(this.tokenSet!.access_token!);
62+
return this.tokenSet!;
9063
}
9164
}

src/uma/resource.ts

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { UMAResourceOptions, UMAScopeOptions } from '../interfaces';
2+
import { UMAScope } from './scope';
3+
4+
export class UMAResource {
5+
public options: UMAResourceOptions;
6+
private scopes: UMAScope[] = [];
7+
constructor(options: UMAResourceOptions) {
8+
this.options = options;
9+
this.setScopes(options.scopes);
10+
}
11+
12+
setScopes(scopes: string[] | UMAScopeOptions[] = []): UMAResource {
13+
scopes.forEach((scope: string | UMAScopeOptions) => {
14+
if (typeof scope === 'string')
15+
this.scopes.push(new UMAScope({ name: scope }));
16+
if (typeof scope === 'object') this.scopes.push(new UMAScope(scope));
17+
});
18+
return this;
19+
}
20+
21+
setName(name: string): UMAResource {
22+
this.options.name = name;
23+
return this;
24+
}
25+
26+
setUri(uri: string): UMAResource {
27+
this.options.uri = uri;
28+
return this;
29+
}
30+
31+
setType(type: string): UMAResource {
32+
this.options.type = type;
33+
return this;
34+
}
35+
36+
setOwner(owner: string): UMAResource {
37+
this.options.owner = owner;
38+
return this;
39+
}
40+
41+
setId(id: string): UMAResource {
42+
this.options.id = id;
43+
return this;
44+
}
45+
46+
setIconUri(iconUri: string): UMAResource {
47+
this.options.iconUri = iconUri;
48+
return this;
49+
}
50+
51+
toJson() {
52+
return Object.assign({}, this.options, {
53+
scopes: this.scopes.map((s) => s.toJson()),
54+
});
55+
}
56+
57+
isEqual(rawRhs: UMAResource): Boolean {
58+
const rhs = rawRhs.toJson();
59+
return (
60+
rhs.name === this.options.name &&
61+
rhs.id === this.options.id &&
62+
rhs.iconUri === this.options.uri &&
63+
rhs.type === this.options.type
64+
);
65+
}
66+
}

src/uma/scope.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { UMAScopeOptions } from '../interfaces';
2+
3+
export class UMAScope {
4+
private readonly options: UMAScopeOptions;
5+
6+
constructor(opts: UMAScopeOptions) {
7+
this.options = opts;
8+
}
9+
10+
isEqual(rhs: UMAScope) {
11+
return this.toJson().name === rhs.toJson().name;
12+
}
13+
14+
toJson() {
15+
return this.options;
16+
}
17+
}

0 commit comments

Comments
 (0)