Skip to content

Commit 0bb8432

Browse files
authored
feat: add oauth authentication using useRedirectUI custom configuration (#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
1 parent b0a0042 commit 0bb8432

File tree

8 files changed

+241
-5
lines changed

8 files changed

+241
-5
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ custom:
4848
host: 'http://some-host'
4949
schemes: ['http', 'https', 'ws', 'wss']
5050
excludeStages: ['production', 'anyOtherStage']
51-
lambdaAuthorizer?: ${self:custom.myAuthorizer}
51+
lambdaAuthorizer: ${self:custom.myAuthorizer}
52+
useRedirectUI: true | false
5253
```
5354

5455
| Option | Description | Default | Example |
@@ -68,6 +69,7 @@ custom:
6869
| `version` | String to overwrite the project version with a custom one | `1` | |
6970
| `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']` | |
7071
| `useStage` | Boolean to either use current stage in beginning of path or not | `false` | `true` => `dev/swagger` for stage `dev` |
72+
| `useRedirectUI` | Boolean to include a path and handler for the oauth2 redirect flow or not | `false` | |
7173

7274
## Adding more details
7375

src/ServerlessAutoSwagger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ export default class ServerlessAutoSwagger {
259259
operationId: http.operationId || `${functionName}.${method}.${http.path}`,
260260
consumes: http.consumes ?? ['application/json'],
261261
produces: http.produces ?? ['application/json'],
262+
security: http.security,
262263
// This is actually type `HttpEvent | HttpApiEvent`, but we can lie since only HttpEvent params (or shared params) are used
263264
parameters: this.httpEventToParameters(http as CustomHttpEvent),
264265
responses: this.formatResponses(http.responseData ?? http.responses),

src/resources/functions.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
'use strict';
22
import { ApiType, CustomHttpApiEvent, CustomServerless, ServerlessFunction } from '../types/serverless-plugin.types';
33

4-
export default (serverless: CustomServerless): Record<'swaggerUI' | 'swaggerJSON', ServerlessFunction> => {
4+
type PartialRecord<K extends keyof any, T> = Partial<Record<K, T>>;
5+
6+
export default (
7+
serverless: CustomServerless
8+
): PartialRecord<'swaggerUI' | 'swaggerJSON' | 'swaggerRedirectURI', ServerlessFunction> => {
59
const handlerPath = 'swagger/';
610
const configInput = serverless?.configurationInput || serverless.service;
711
const path = serverless.service.custom?.autoswagger?.swaggerPath ?? 'swagger';
@@ -46,5 +50,24 @@ export default (serverless: CustomServerless): Record<'swaggerUI' | 'swaggerJSON
4650
],
4751
};
4852

49-
return { swaggerUI, swaggerJSON };
53+
const swaggerRedirectURI: ServerlessFunction | undefined = serverless.service.custom?.autoswagger?.useRedirectUI
54+
? {
55+
name: name && stage ? `${name}-${stage}-swagger-redirect-uri` : undefined,
56+
handler: handlerPath + 'oauth2-redirect-html.handler',
57+
events: [
58+
{
59+
[apiType as 'httpApi']: {
60+
method: 'get' as const,
61+
path: useStage ? `/${stage}/oauth2-redirect.html` : `/oauth2-redirect.html`,
62+
},
63+
},
64+
],
65+
}
66+
: undefined;
67+
68+
return {
69+
swaggerUI,
70+
swaggerJSON,
71+
...(swaggerRedirectURI && { swaggerRedirectURI }),
72+
};
5073
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
exports.handler = async () => {
2+
return {
3+
statusCode: 200,
4+
body: redirectUI,
5+
headers: {
6+
'content-type': 'text/html',
7+
},
8+
};
9+
};
10+
11+
// copied from view-source:https://unpkg.com/[email protected]/oauth2-redirect.html
12+
const redirectUI = `<!doctype html>
13+
<html lang="en-US">
14+
<head>
15+
<title>Swagger UI: OAuth2 Redirect</title>
16+
</head>
17+
<body>
18+
<script>
19+
'use strict';
20+
function run () {
21+
var oauth2 = window.opener.swaggerUIRedirectOauth2;
22+
var sentState = oauth2.state;
23+
var redirectUrl = oauth2.redirectUrl;
24+
var isValid, qp, arr;
25+
26+
if (/code|token|error/.test(window.location.hash)) {
27+
qp = window.location.hash.substring(1);
28+
} else {
29+
qp = location.search.substring(1);
30+
}
31+
32+
arr = qp.split("&");
33+
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
34+
qp = qp ? JSON.parse('{' + arr.join() + '}',
35+
function (key, value) {
36+
return key === "" ? value : decodeURIComponent(value);
37+
}
38+
) : {};
39+
40+
isValid = qp.state === sentState;
41+
42+
if ((
43+
oauth2.auth.schema.get("flow") === "accessCode" ||
44+
oauth2.auth.schema.get("flow") === "authorizationCode" ||
45+
oauth2.auth.schema.get("flow") === "authorization_code"
46+
) && !oauth2.auth.code) {
47+
if (!isValid) {
48+
oauth2.errCb({
49+
authId: oauth2.auth.name,
50+
source: "auth",
51+
level: "warning",
52+
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
53+
});
54+
}
55+
56+
if (qp.code) {
57+
delete oauth2.state;
58+
oauth2.auth.code = qp.code;
59+
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
60+
} else {
61+
let oauthErrorMsg;
62+
if (qp.error) {
63+
oauthErrorMsg = "["+qp.error+"]: " +
64+
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
65+
(qp.error_uri ? "More info: "+qp.error_uri : "");
66+
}
67+
68+
oauth2.errCb({
69+
authId: oauth2.auth.name,
70+
source: "auth",
71+
level: "error",
72+
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
73+
});
74+
}
75+
} else {
76+
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
77+
}
78+
window.close();
79+
}
80+
81+
window.addEventListener('DOMContentLoaded', function () {
82+
run();
83+
});
84+
</script>
85+
</body>
86+
</html>`;

src/resources/swagger-html.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,50 @@ const swaggerUI = `<!DOCTYPE html>
1818
rel="stylesheet"
1919
href="https://unpkg.com/[email protected]/swagger-ui.css"
2020
/>
21-
21+
<script src="https://unpkg.com/react@15/dist/react.min.js"></script>
2222
<script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js"></script>
2323
<script src="https://unpkg.com/[email protected]/swagger-ui-standalone-preset.js"></script>
2424
<script defer>
2525
window.onload = () => {
26+
const h = React.createElement
2627
const ui = SwaggerUIBundle({
2728
url: window.location.href + '.json',
2829
dom_id: '#swagger-ui',
29-
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
30+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset,
31+
system => {
32+
// Variable to capture the security prop of OperationSummary
33+
// then pass it to authorizeOperationBtn
34+
let currentSecurity
35+
return {
36+
wrapComponents: {
37+
// Wrap OperationSummary component to get its prop
38+
OperationSummary: Original => props => {
39+
const security = props.operationProps.get('security')
40+
currentSecurity = security.toJS()
41+
return h(Original, props)
42+
},
43+
// Wrap the padlock button to show the
44+
// scopes required for current operation
45+
authorizeOperationBtn: Original =>
46+
function (props) {
47+
return h('div', {}, [
48+
...(currentSecurity || []).map(scheme => {
49+
const schemeName = Object.keys(scheme)[0]
50+
if (!scheme[schemeName].length) return null
51+
52+
const scopes = scheme[schemeName].flatMap(scope => [
53+
h('code', null, scope),
54+
', ',
55+
])
56+
scopes.pop()
57+
return h('span', null, scopes)
58+
}),
59+
h(Original, props),
60+
])
61+
},
62+
},
63+
}
64+
}],
3065
layout: 'StandaloneLayout',
3166
});
3267
window.ui = ui;

src/schemas/custom-properties.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@
8686
"default": "swagger",
8787
"type": "string"
8888
},
89+
"useRedirectUI": {
90+
"default": "false",
91+
"type": "boolean"
92+
},
8993
"typefiles": {
9094
"default": [],
9195
"items": {

src/types/serverless-plugin.types.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import type {
1212
Serverless,
1313
} from 'serverless/aws';
1414
import type { HttpMethod } from './common.types';
15+
import { MethodSecurity } from './swagger.types';
1516

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

4952
export type CustomWithAutoSwagger = Custom & { autoswagger?: AutoSwaggerCustomConfig };
@@ -111,6 +114,7 @@ export interface CustomHttpEvent extends Http {
111114
headerParameters?: HeaderParameters;
112115
queryStringParameters?: QueryStringParameters;
113116
operationId?: string;
117+
security?: MethodSecurity[];
114118
}
115119

116120
export interface CustomHttpApiEvent extends HttpApiEvent {
@@ -127,6 +131,7 @@ export interface CustomHttpApiEvent extends HttpApiEvent {
127131
headerParameters?: string;
128132
queryStringParameterType?: string;
129133
operationId?: string;
134+
security?: MethodSecurity[];
130135
}
131136

132137
export interface HttpResponses {

tests/functions.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import swaggerFunctions from '../src/resources/functions';
2+
import { AutoSwaggerCustomConfig, CustomServerless } from '../src/types/serverless-plugin.types';
3+
4+
const defaultServiceDetails: CustomServerless = {
5+
configSchemaHandler: {
6+
defineCustomProperties: () => undefined,
7+
defineFunctionEvent: () => undefined,
8+
defineFunctionEventProperties: () => undefined,
9+
defineFunctionProperties: () => undefined,
10+
defineProvider: () => undefined,
11+
defineTopLevelProperty: () => undefined,
12+
},
13+
configurationInput: {},
14+
service: {
15+
service: '',
16+
provider: {
17+
name: 'aws',
18+
runtime: undefined,
19+
stage: '',
20+
region: undefined,
21+
profile: '',
22+
environment: {},
23+
},
24+
plugins: [],
25+
functions: {
26+
mocked: {
27+
handler: 'mocked.handler',
28+
},
29+
},
30+
custom: {
31+
autoswagger: {},
32+
},
33+
},
34+
};
35+
36+
type GetCustomServerlessConfigParams = {
37+
autoswaggerOptions?: AutoSwaggerCustomConfig;
38+
};
39+
40+
const getCustomServerlessConfig = ({ autoswaggerOptions }: GetCustomServerlessConfigParams = {}): CustomServerless => ({
41+
...defaultServiceDetails,
42+
service: {
43+
...defaultServiceDetails.service,
44+
custom: {
45+
...defaultServiceDetails.service.custom,
46+
autoswagger: {
47+
...defaultServiceDetails.service.custom?.autoswagger,
48+
...autoswaggerOptions,
49+
},
50+
},
51+
},
52+
});
53+
54+
describe('swaggerFunctions tests', () => {
55+
it('includes swaggerRedirectURI if useRedirectUI is set to true', () => {
56+
const serviceDetails = getCustomServerlessConfig({
57+
autoswaggerOptions: {
58+
useRedirectUI: true,
59+
},
60+
});
61+
const result = swaggerFunctions(serviceDetails);
62+
expect(Object.keys(result)).toContain('swaggerRedirectURI');
63+
});
64+
65+
it('does not includes swaggerRedirectURI if useRedirectUI is set to false', () => {
66+
const serviceDetails = getCustomServerlessConfig({
67+
autoswaggerOptions: {
68+
useRedirectUI: false,
69+
},
70+
});
71+
const result = swaggerFunctions(serviceDetails);
72+
expect(Object.keys(result)).not.toContain('swaggerRedirectURI');
73+
});
74+
75+
it('does not includes swaggerRedirectURI if useRedirectUI is not set', () => {
76+
const serviceDetails = getCustomServerlessConfig();
77+
const result = swaggerFunctions(serviceDetails);
78+
expect(Object.keys(result)).not.toContain('swaggerRedirectURI');
79+
});
80+
});

0 commit comments

Comments
 (0)