Skip to content

Commit

Permalink
feat: add oauth authentication using useRedirectUI custom configura…
Browse files Browse the repository at this point in the history
…tion (#105)

* * creating new parameter `useRedirectUI` that enables hosting an endpoint for using the standard OAuth 2.0 Authorization Code Grant Flow.
* Updating swagger plugin to include middleware that puts scopes next to the authorization widget for each endpoint
* PR fixes
* copy pasta error
  • Loading branch information
john-arccos authored Dec 2, 2022
1 parent b0a0042 commit 0bb8432
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 5 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ custom:
host: 'http://some-host'
schemes: ['http', 'https', 'ws', 'wss']
excludeStages: ['production', 'anyOtherStage']
lambdaAuthorizer?: ${self:custom.myAuthorizer}
lambdaAuthorizer: ${self:custom.myAuthorizer}
useRedirectUI: true | false
```

| Option | Description | Default | Example |
Expand All @@ -68,6 +69,7 @@ custom:
| `version` | String to overwrite the project version with a custom one | `1` | |
| `typefiles` | Array of strings which defines where to find the typescript types to use for the request and response bodies | `['./src/types/api-types.d.ts']` | |
| `useStage` | Boolean to either use current stage in beginning of path or not | `false` | `true` => `dev/swagger` for stage `dev` |
| `useRedirectUI` | Boolean to include a path and handler for the oauth2 redirect flow or not | `false` | |

## Adding more details

Expand Down
1 change: 1 addition & 0 deletions src/ServerlessAutoSwagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export default class ServerlessAutoSwagger {
operationId: http.operationId || `${functionName}.${method}.${http.path}`,
consumes: http.consumes ?? ['application/json'],
produces: http.produces ?? ['application/json'],
security: http.security,
// This is actually type `HttpEvent | HttpApiEvent`, but we can lie since only HttpEvent params (or shared params) are used
parameters: this.httpEventToParameters(http as CustomHttpEvent),
responses: this.formatResponses(http.responseData ?? http.responses),
Expand Down
27 changes: 25 additions & 2 deletions src/resources/functions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
'use strict';
import { ApiType, CustomHttpApiEvent, CustomServerless, ServerlessFunction } from '../types/serverless-plugin.types';

export default (serverless: CustomServerless): Record<'swaggerUI' | 'swaggerJSON', ServerlessFunction> => {
type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;

export default (
serverless: CustomServerless
): PartialRecord<'swaggerUI' | 'swaggerJSON' | 'swaggerRedirectURI', ServerlessFunction> => {
const handlerPath = 'swagger/';
const configInput = serverless?.configurationInput || serverless.service;
const path = serverless.service.custom?.autoswagger?.swaggerPath ?? 'swagger';
Expand Down Expand Up @@ -46,5 +50,24 @@ export default (serverless: CustomServerless): Record<'swaggerUI' | 'swaggerJSON
],
};

return { swaggerUI, swaggerJSON };
const swaggerRedirectURI: ServerlessFunction | undefined = serverless.service.custom?.autoswagger?.useRedirectUI
? {
name: name && stage ? `${name}-${stage}-swagger-redirect-uri` : undefined,
handler: handlerPath + 'oauth2-redirect-html.handler',
events: [
{
[apiType as 'httpApi']: {
method: 'get' as const,
path: useStage ? `/${stage}/oauth2-redirect.html` : `/oauth2-redirect.html`,
},
},
],
}
: undefined;

return {
swaggerUI,
swaggerJSON,
...(swaggerRedirectURI && { swaggerRedirectURI }),
};
};
86 changes: 86 additions & 0 deletions src/resources/oauth2-redirect-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
exports.handler = async () => {
return {
statusCode: 200,
body: redirectUI,
headers: {
'content-type': 'text/html',
},
};
};

// copied from view-source:https://unpkg.com/[email protected]/oauth2-redirect.html
const redirectUI = `<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
window.addEventListener('DOMContentLoaded', function () {
run();
});
</script>
</body>
</html>`;
39 changes: 37 additions & 2 deletions src/resources/swagger-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,50 @@ const swaggerUI = `<!DOCTYPE html>
rel="stylesheet"
href="https://unpkg.com/[email protected]/swagger-ui.css"
/>
<script src="https://unpkg.com/react@15/dist/react.min.js"></script>
<script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/[email protected]/swagger-ui-standalone-preset.js"></script>
<script defer>
window.onload = () => {
const h = React.createElement
const ui = SwaggerUIBundle({
url: window.location.href + '.json',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset,
system => {
// Variable to capture the security prop of OperationSummary
// then pass it to authorizeOperationBtn
let currentSecurity
return {
wrapComponents: {
// Wrap OperationSummary component to get its prop
OperationSummary: Original => props => {
const security = props.operationProps.get('security')
currentSecurity = security.toJS()
return h(Original, props)
},
// Wrap the padlock button to show the
// scopes required for current operation
authorizeOperationBtn: Original =>
function (props) {
return h('div', {}, [
...(currentSecurity || []).map(scheme => {
const schemeName = Object.keys(scheme)[0]
if (!scheme[schemeName].length) return null
const scopes = scheme[schemeName].flatMap(scope => [
h('code', null, scope),
', ',
])
scopes.pop()
return h('span', null, scopes)
}),
h(Original, props),
])
},
},
}
}],
layout: 'StandaloneLayout',
});
window.ui = ui;
Expand Down
4 changes: 4 additions & 0 deletions src/schemas/custom-properties.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@
"default": "swagger",
"type": "string"
},
"useRedirectUI": {
"default": "false",
"type": "boolean"
},
"typefiles": {
"default": [],
"items": {
Expand Down
5 changes: 5 additions & 0 deletions src/types/serverless-plugin.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import type {
Serverless,
} from 'serverless/aws';
import type { HttpMethod } from './common.types';
import { MethodSecurity } from './swagger.types';

export type CustomServerless = {
// eslint-disable-next-line no-unused-vars
cli?: { log: (text: string) => void }; // deprecated and replaced in v3.0.0
service: ServerlessConfig;
configSchemaHandler: configSchemaHandler;
Expand Down Expand Up @@ -44,6 +46,7 @@ export interface AutoSwaggerCustomConfig {
version?: string;
excludeStages?: string[];
lambdaAuthorizer?: Http['authorizer'] | HttpApiEvent['authorizer'];
useRedirectUI?: boolean;
}

export type CustomWithAutoSwagger = Custom & { autoswagger?: AutoSwaggerCustomConfig };
Expand Down Expand Up @@ -111,6 +114,7 @@ export interface CustomHttpEvent extends Http {
headerParameters?: HeaderParameters;
queryStringParameters?: QueryStringParameters;
operationId?: string;
security?: MethodSecurity[];
}

export interface CustomHttpApiEvent extends HttpApiEvent {
Expand All @@ -127,6 +131,7 @@ export interface CustomHttpApiEvent extends HttpApiEvent {
headerParameters?: string;
queryStringParameterType?: string;
operationId?: string;
security?: MethodSecurity[];
}

export interface HttpResponses {
Expand Down
80 changes: 80 additions & 0 deletions tests/functions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import swaggerFunctions from '../src/resources/functions';
import { AutoSwaggerCustomConfig, CustomServerless } from '../src/types/serverless-plugin.types';

const defaultServiceDetails: CustomServerless = {
configSchemaHandler: {
defineCustomProperties: () => undefined,
defineFunctionEvent: () => undefined,
defineFunctionEventProperties: () => undefined,
defineFunctionProperties: () => undefined,
defineProvider: () => undefined,
defineTopLevelProperty: () => undefined,
},
configurationInput: {},
service: {
service: '',
provider: {
name: 'aws',
runtime: undefined,
stage: '',
region: undefined,
profile: '',
environment: {},
},
plugins: [],
functions: {
mocked: {
handler: 'mocked.handler',
},
},
custom: {
autoswagger: {},
},
},
};

type GetCustomServerlessConfigParams = {
autoswaggerOptions?: AutoSwaggerCustomConfig;
};

const getCustomServerlessConfig = ({ autoswaggerOptions }: GetCustomServerlessConfigParams = {}): CustomServerless => ({
...defaultServiceDetails,
service: {
...defaultServiceDetails.service,
custom: {
...defaultServiceDetails.service.custom,
autoswagger: {
...defaultServiceDetails.service.custom?.autoswagger,
...autoswaggerOptions,
},
},
},
});

describe('swaggerFunctions tests', () => {
it('includes swaggerRedirectURI if useRedirectUI is set to true', () => {
const serviceDetails = getCustomServerlessConfig({
autoswaggerOptions: {
useRedirectUI: true,
},
});
const result = swaggerFunctions(serviceDetails);
expect(Object.keys(result)).toContain('swaggerRedirectURI');
});

it('does not includes swaggerRedirectURI if useRedirectUI is set to false', () => {
const serviceDetails = getCustomServerlessConfig({
autoswaggerOptions: {
useRedirectUI: false,
},
});
const result = swaggerFunctions(serviceDetails);
expect(Object.keys(result)).not.toContain('swaggerRedirectURI');
});

it('does not includes swaggerRedirectURI if useRedirectUI is not set', () => {
const serviceDetails = getCustomServerlessConfig();
const result = swaggerFunctions(serviceDetails);
expect(Object.keys(result)).not.toContain('swaggerRedirectURI');
});
});

0 comments on commit 0bb8432

Please sign in to comment.