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/access endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
vlad-tkachenko committed May 27, 2020
1 parent 40ac74c commit 389487a
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 167 deletions.
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ keycloak-security-gateway can be used to secure any kind of app, either it is a
- `APP_UPSTREAM_URL` - Upstream URL to forward requests to
- `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_ACCESS` - Access verification endpoint path. Endpoint allows to check if client has access to specific resource, e.g. `/oauth/access`
- `APP_PATHS_CALLBACK` - Routing path to use for SSO authentication callback, e.g. `/oauth/callback`
- `APP_PATHS_LOGOUT` - Logout path to use, e.g. `/oauth/logout`
- `APP_PATHS_HEALTH` - Gateway health endpoint, e.g. `/healthz`
Expand Down Expand Up @@ -150,3 +151,32 @@ Slow, as every request will be verified with Keycloak, however guarantees that i
### User has role, but still could not access resource.
Make sure client has all the necessary roles included in the scope, or "Full Scope Allowed" toggle is turned on.
## Endpoints
### Access Endpoint
Allows to check if client can access specified resource.
Request requires 2 query parameters to be provided:
- `path` resource path to check, e.g. `/api/users`
- `method` HTTP request method for the resource access, e.g. `GET`

Response example:

```json
{
"allowed": true
}
```

### Roles Endpoint

Allows to retrieve all the roles JWT contains. Can be used by client application, though it is recommended to use access endpoint to check individual endpoints for access instead.

Response example:

```json
["realm_role", "client_id:client_role"]
```
1 change: 1 addition & 0 deletions config/custom-environment-variables.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ keycloak:
upstreamURL: APP_UPSTREAM_URL

paths:
access: APP_PATHS_ACCESS
callback: APP_PATHS_CALLBACK
logout: APP_PATHS_LOGOUT
health: APP_PATHS_HEALTH
Expand Down
3 changes: 3 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ paths:
callback: /oauth/callback
# User logout path
logout: /oauth/logout
# Check resource access path
access: /oauth/access
# User roles
roles: /oauth/roles
# Application healthcheck endpoint
health: /healthz


# Redirect URL to be used upon logout
logoutRedirectURL: /

Expand Down
122 changes: 16 additions & 106 deletions src/RequestProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import { IncomingMessage, ServerResponse } from 'http';
import { createProxyServer } from 'http-proxy';
import { get } from 'config';
import { IResourceDefinition, ITargetPathResult } from './interfaces';
import { ITargetPathResult } from './interfaces';
import { JWT } from './models/JWT';
import { $log } from 'ts-log-debug';
import * as ejs from 'ejs';
import { sendError, sendRedirect, setAuthCookies } from './utils/ResponseUtil';
import { extractAccessToken, extractRefreshToken, extractRequestPath } from './utils/RequestUtil';
import { findResourceByPathAndMethod } from './utils/ResourceUtils';

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

import { prepareAuthURL, verifyOnline, verifyOffline, handleCallbackRequest, refresh } from './utils/KeycloakUtil';
import { IClientConfiguration } from './interfaces/IClientConfiguration';

export class RequestProcessor {
private proxy = createProxyServer();
private upstreamURL: string = get('upstreamURL');
private callbackPath: string = get('paths.callback');
private logoutPath: string = get('paths.logout');
private healthPath: string = get('paths.health');
private accessPath: string = get('paths.access');
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';
private requestHeaders: Record<string, string> = get('request.headers');
private responseHeaders: Record<string, string> = get('response.headers');
Expand All @@ -45,36 +45,6 @@ export class RequestProcessor {
}
});

if (!this.resources) {
this.resources = [
{
match: '.*',
},
];
}

for (const resource of this.resources) {
if (resource.ssoFlow) {
if (!resource.clientSID) {
throw new Error(
`"clientSID" is missing in resource definition that matches: "${resource.match}" and has ssoFlow enabled`,
);
}
}

let match = resource.match;

if (match.indexOf('^') !== 0) {
match = '^' + match;
}

if (!match.endsWith('$')) {
match = match + '$';
}

resource.matchPattern = new RegExp(match, 'i');
}

/* istanbul ignore else */
if (this.upstreamURL[this.upstreamURL.length - 1] === '/') {
this.upstreamURL = this.upstreamURL.substring(0, this.upstreamURL.length - 1);
Expand All @@ -100,13 +70,13 @@ export class RequestProcessor {
}

/**
* Handle unathorized flow
* Handle unauthorized flow
* @param req
* @param res
* @param path
* @param result
*/
private async handleUnathorizedFlow(
private async handleUnauthorizedFlow(
req: IncomingMessage,
res: ServerResponse,
path: string,
Expand Down Expand Up @@ -161,7 +131,7 @@ export class RequestProcessor {
let jwt: JWT;

if (!token) {
token = await this.handleUnathorizedFlow(req, res, path, result);
token = await this.handleUnauthorizedFlow(req, res, path, result);

if (!token) {
return null;
Expand All @@ -177,7 +147,7 @@ export class RequestProcessor {
}

if (!jwt) {
token = await this.handleUnathorizedFlow(req, res, path, result);
token = await this.handleUnauthorizedFlow(req, res, path, result);

if (!token) {
return null;
Expand Down Expand Up @@ -262,30 +232,26 @@ export class RequestProcessor {
$log.info(`New request received. method: ${req.method}; path: ${path}`);

if (path === this.callbackPath) {
await this.handleCallback(req, res);

return;
return await this.handleCallback(req, res);
}

if (path === this.logoutPath) {
await handleLogoutRoute(req, res);

return;
return await handleLogoutRoute(req, res);
}

if (path === this.healthPath) {
await handleHealthRoute(res);

return;
return await handleHealthRoute(res);
}

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

return;
if (path === this.accessPath) {
return await handleAccessRoute(req, res);
}

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

if (!result) {
await sendError(res, 404, 'Not found');
Expand Down Expand Up @@ -321,60 +287,4 @@ export class RequestProcessor {
sendError(res, 500, 'Unexpected error');
});
}

/**
* Find target path
* @param req
* @param path
*/
private findTargetPath(req: IncomingMessage, path: string): ITargetPathResult | null {
$log.debug('Looking to find destination path');

for (const resource of this.resources) {
// first check if method is listed
if (resource.methods && resource.methods.indexOf(req.method) < 0) {
continue;
}

// try to match pattern
const match = resource.matchPattern.exec(path);
if (!match) {
continue;
}

const result = {
path,
resource: {
...resource,
},
};

if (resource.clientSID && resource.clientSID[0] === '$') {
result.resource.clientSID = match[Number(resource.clientSID.substring(1))];
}

if (result.resource.clientSID) {
result.resource.clientConfiguration = this.clientConfigurations.find(
(c) => c.sid === result.resource.clientSID,
);
if (!result.resource.clientConfiguration) {
throw new Error(
`Unable to find matching client configuration for clientSID "${result.resource.clientSID}" that matches: "${resource.match}" and has ssoFlow enabled`,
);
}
}

if (resource.override) {
result.path = path.replace(resource.matchPattern, resource.override);
}

$log.debug('Destination resource found', result);

return result;
}

$log.debug('Destination resource not found');

return null;
}
}
52 changes: 52 additions & 0 deletions src/routes/AccessRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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';
import { findResourceByPathAndMethod } from '../utils/ResourceUtils';
import { parse } from 'url';

const handler = async (req: IncomingMessage, res: ServerResponse) => {
$log.debug('Handling logout request');
const { path, method } = parse(req.url, true).query;

if (!path) {
return await sendError(res, 400, '"path" query parameter is missing');
}

if (!method) {
return await sendError(res, 400, '"method" query parameter is missing');
}

const targetPath = findResourceByPathAndMethod(path.toString(), method.toString());

if (!targetPath) {
return await sendError(res, 404, 'Mapping not found');
}

if (!targetPath.resource.public) {
const accessToken = extractAccessToken(req);

if (!accessToken) {
return await sendJSONResponse(res, {
allowed: false,
});
}

const jwtToken = new JWT(accessToken);
if (targetPath.resource.roles) {
if (!jwtToken.verifyRoles(targetPath.resource.roles)) {
return await sendJSONResponse(res, {
allowed: false,
});
}
}
}

return await sendJSONResponse(res, {
allowed: true,
});
};

export default handler;
Loading

0 comments on commit 389487a

Please sign in to comment.