Skip to content
This repository has been archived by the owner on Dec 1, 2023. It is now read-only.

Commit

Permalink
feat: add /oauth/roles endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
vlad-tkachenko committed May 27, 2020
1 parent 8fbc575 commit 49fba95
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 5 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ keycloak-security-gateway can be used to secure any kind of app, either it is a
| JWT online verification strategy | Yes | No |
| Flexible role based access (like: at least one of the roles required, not simply all) | Yes | Yes (either all or at least one, but not both rules at the same time) |
| Multiple KC clients supported | Yes | No |
| Roles endpoint to get all roles from JWT token (no need to parse JWT on SPA side) | Yes | No |

## Configuration

Expand All @@ -47,8 +48,9 @@ keycloak-security-gateway can be used to secure any kind of app, either it is a
- `APP_LOGOUT_REDIRECT_URL` - URL or relative path to redirect user after logout. User can provide a query parameter `redirectTo` to override this setting on per request level.

- `APP_PATHS_CALLBACK` - Routing path to use for SSO authentication callback, e.g. `/oauth/callback`
- `APP_PATHS_LOGOUT` - Logout path to use, e.g. `/logout`
- `APP_PATHS_LOGOUT` - Logout path to use, e.g. `/oauth/logout`
- `APP_PATHS_HEALTH` - Gateway health endpoint, e.g. `/healthz`
- `APP_PATHS_ROLES` - Roles endpoint, e.g. `/oauth/roles`, endpoint extracts all roles from JWT including realm and client ones and returns all as single JSON array.

- `APP_COOKIE_SECURE` - Either cookies should be used only with HTTPS connection
- `APP_COOKIE_ACCESS_TOKEN` - Access Token cookie name
Expand Down
1 change: 1 addition & 0 deletions config/custom-environment-variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ paths:
callback: APP_PATHS_CALLBACK
logout: APP_PATHS_LOGOUT
health: APP_PATHS_HEALTH
roles: APP_PATHS_ROLES

logoutRedirectURL: APP_LOGOUT_REDIRECT_URL

Expand Down
4 changes: 3 additions & 1 deletion config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ paths:
# OID callback path
callback: /oauth/callback
# User logout path
logout: /logout
logout: /oauth/logout
# User roles
roles: /oauth/roles
# Application healthcheck endpoint
health: /healthz

Expand Down
3 changes: 2 additions & 1 deletion config/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ upstreamURL: http://localhost:7777/
scopes: []

paths:
callback: /oauth/callback
callback: /callback
roles: /roles
logout: /logout
health: /healthz

Expand Down
10 changes: 9 additions & 1 deletion src/RequestProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { extractAccessToken, extractRefreshToken, extractRequestPath } from './u

import handleHealthRoute from './routes/HealthRoute';
import handleLogoutRoute from './routes/LogoutRoute';
import handleRolesRoute from './routes/RolesRoute';

import { prepareAuthURL, verifyOnline, verifyOffline, handleCallbackRequest, refresh } from './utils/KeycloakUtil';
import { IClientConfiguration } from './interfaces/IClientConfiguration';
Expand All @@ -20,6 +21,7 @@ export class RequestProcessor {
private callbackPath: string = get('paths.callback');
private logoutPath: string = get('paths.logout');
private healthPath: string = get('paths.health');
private rolesPath: string = get('paths.roles');
private resources: IResourceDefinition[] = JSON.parse(JSON.stringify(get('resources')));
private clientConfigurations: IClientConfiguration[] = get('keycloak.clients');
private jwtVerificationOnline = get('jwtVerification') === 'ONLINE';
Expand Down Expand Up @@ -136,7 +138,7 @@ export class RequestProcessor {
const authURL = prepareAuthURL(result.resource.clientConfiguration, path);
await sendRedirect(res, authURL);
} else {
await sendError(res, 401, 'Unathorized');
await sendError(res, 401, 'Unauthorized');
}

return null;
Expand Down Expand Up @@ -277,6 +279,12 @@ export class RequestProcessor {
return;
}

if (path === this.rolesPath) {
await handleRolesRoute(req, res);

return;
}

const result = this.findTargetPath(req, path);

if (!result) {
Expand Down
23 changes: 23 additions & 0 deletions src/routes/RolesRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ServerResponse, IncomingMessage } from 'http';
import { $log } from 'ts-log-debug';
import { extractAccessToken } from '../utils/RequestUtil';
import { sendError } from '../utils/ResponseUtil';
import { sendJSONResponse } from '../utils/ResponseUtil';
import { JWT } from '../models/JWT';

const handler = async (req: IncomingMessage, res: ServerResponse) => {
$log.debug('Handling logout request');
const accessToken = extractAccessToken(req);

if (!accessToken) {
await sendError(res, 401, 'Unauthorized');

return;
}

const jwtToken = new JWT(accessToken);

await sendJSONResponse(res, jwtToken.getAllRoles());
};

export default handler;
2 changes: 1 addition & 1 deletion test/integration/SSOFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ class SSOFlow {
});

const body = await page.evaluate(() => document.querySelector('pre').innerHTML);
strictEqual(body, 'Unathorized');
strictEqual(body, 'Unauthorized');
}

/**
Expand Down
80 changes: 80 additions & 0 deletions test/integration/routes/RolesRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { suite, test } from 'mocha-typescript';
import { get } from 'config';
import axios from 'axios';
import { strictEqual, ok } from 'assert';
import { start, stop } from '../../../src';
import { IClientConfiguration } from '../../../src/interfaces/IClientConfiguration';
import { stringify } from 'querystring';

const clients: IClientConfiguration[] = get('keycloak.clients');

@suite()
class RolesRoute {
async before() {
await start();
}

async after() {
await stop();
}

@test()
async makeRolesRequest() {
const request = axios.create({
baseURL: get('host'),
});

const accessToken = await this.getAccessToken();

const response = await request.get(get('paths.roles'), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

ok(response.data.indexOf('test:uma_protection') >= 0);
ok(response.data.indexOf('offline_access') >= 0);
}

@test()
async makeUnauthorizedRolesRequest() {
const request = axios.create({
baseURL: get('host'),
});

let code = 0;
try {
await request.get(get('paths.roles'));
} catch (e) {
code = e.response.status;
}

strictEqual(code, 401);
}

/**
* Authenticate with service account
*/
private async getAccessToken(): Promise<string> {
console.log('-> Retrieving access_token from KC...');
const request = axios.create({
baseURL: clients[0].realmURL.private,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});

const result = await request('/protocol/openid-connect/token', {
method: 'POST',
data: stringify({
grant_type: 'client_credentials',
client_id: 'test',
client_secret: clients[0].secret,
}),
});

console.log(`-> Access token: ${result.data.access_token}`);

return result.data.access_token;
}
}

0 comments on commit 49fba95

Please sign in to comment.