Skip to content

Commit 139592e

Browse files
poveilleuxshockey
authored andcommittedOct 8, 2019
feat: add PKCE support for OAuth2 Authorization Code flows (#5361)
* Add PKCE support. * Fix tests * Update oauth2.md * Rename usePkce * Fix the BrokenComponent error * Update oauth2.md * Remove isCode variable. Remove uuid4 dependency. * Remove utils functions * Import crypto * Fix tests * Fix the tests * Cleanup * Fix code_challenge generation * Move code challenge and verifier to utils for mocks. Update tests. * Mock the PKCE methods in the utils file properly. * Add missing expect * use target-method spies * Add comments to explain test values. * Get rid of jsrsasign.
1 parent 8cabcff commit 139592e

File tree

12 files changed

+13917
-18056
lines changed

12 files changed

+13917
-18056
lines changed
 

‎dev-helpers/index.html

+2-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
realm: "your-realms",
6363
appName: "your-app-name",
6464
scopeSeparator: " ",
65-
additionalQueryStringParams: {}
65+
additionalQueryStringParams: {},
66+
usePkceWithAuthorizationCodeGrant: false
6667
})
6768
}
6869
</script>

‎docker/configurator/oauth.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const translator = require("./translator")
22
const indent = require("./helpers").indent
33

4-
const oauthBlockSchema = {
4+
const oauthBlockSchema = {
55
OAUTH_CLIENT_ID: {
66
type: "string",
77
name: "clientId"
@@ -26,6 +26,10 @@ const oauthBlockSchema = {
2626
OAUTH_ADDITIONAL_PARAMS: {
2727
type: "object",
2828
name: "additionalQueryStringParams"
29+
},
30+
OAUTH_USE_PKCE: {
31+
type: "boolean",
32+
name: "usePkceWithAuthorizationCodeGrant"
2933
}
3034
}
3135

‎docs/usage/oauth2.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ appName | `OAUTH_APP_NAME` |application name, displayed in authorization popup.
1010
scopeSeparator | `OAUTH_SCOPE_SEPARATOR` |scope separator for passing scopes, encoded before calling, default value is a space (encoded value `%20`). MUST be a string
1111
additionalQueryStringParams | `OAUTH_ADDITIONAL_PARAMS` |Additional query parameters added to `authorizationUrl` and `tokenUrl`. MUST be an object
1212
useBasicAuthenticationWithAccessCodeGrant | _Unavailable_ |Only activated for the `accessCode` flow. During the `authorization_code` request to the `tokenUrl`, pass the [Client Password](https://tools.ietf.org/html/rfc6749#section-2.3.1) using the HTTP Basic Authentication scheme (`Authorization` header with `Basic base64encode(client_id + client_secret)`). The default is `false`
13+
usePkceWithAuthorizationCodeGrant | `OAUTH_USE_PKCE` | Only applies to `authorizatonCode` flows. [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) brings enhanced security for OAuth public clients. The default is `false`
1314

1415
```javascript
1516
const ui = SwaggerUI({...})
@@ -21,6 +22,7 @@ ui.initOAuth({
2122
realm: "your-realms",
2223
appName: "your-app-name",
2324
scopeSeparator: " ",
24-
additionalQueryStringParams: {test: "hello"}
25+
additionalQueryStringParams: {test: "hello"},
26+
usePkceWithAuthorizationCodeGrant: true
2527
})
2628
```

‎package-lock.json

+13,761-18,037
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/core/oauth2-authorize.js

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import win from "core/window"
2-
import { btoa, sanitizeUrl } from "core/utils"
2+
import { btoa, sanitizeUrl, generateCodeVerifier, createCodeChallenge } from "core/utils"
33

44
export default function authorize ( { auth, authActions, errActions, configs, authConfigs={} } ) {
55
let { schema, scopes, name, clientId } = auth
@@ -66,6 +66,18 @@ export default function authorize ( { auth, authActions, errActions, configs, au
6666
query.push("realm=" + encodeURIComponent(authConfigs.realm))
6767
}
6868

69+
if (flow === "authorizationCode" && authConfigs.usePkceWithAuthorizationCodeGrant) {
70+
const codeVerifier = generateCodeVerifier()
71+
const codeChallenge = createCodeChallenge(codeVerifier)
72+
73+
query.push("code_challenge=" + codeChallenge)
74+
query.push("code_challenge_method=S256")
75+
76+
// storing the Code Verifier so it can be sent to the token endpoint
77+
// when exchanging the Authorization Code for an Access Token
78+
auth.codeVerifier = codeVerifier
79+
}
80+
6981
let { additionalQueryStringParams } = authConfigs
7082

7183
for (let key in additionalQueryStringParams) {

‎src/core/plugins/auth/actions.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,14 @@ export const authorizeApplication = ( auth ) => ( { authActions } ) => {
120120
}
121121

122122
export const authorizeAccessCodeWithFormParams = ( { auth, redirectUrl } ) => ( { authActions } ) => {
123-
let { schema, name, clientId, clientSecret } = auth
123+
let { schema, name, clientId, clientSecret, codeVerifier } = auth
124124
let form = {
125125
grant_type: "authorization_code",
126126
code: auth.code,
127127
client_id: clientId,
128128
client_secret: clientSecret,
129-
redirect_uri: redirectUrl
129+
redirect_uri: redirectUrl,
130+
code_verifier: codeVerifier
130131
}
131132

132133
return authActions.authorizeRequest({body: buildFormData(form), name, url: schema.get("tokenUrl"), auth})

‎src/core/utils.js

+24
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { memoizedSampleFromSchema, memoizedCreateXMLExample } from "core/plugins
2222
import win from "./window"
2323
import cssEscape from "css.escape"
2424
import getParameterSchema from "../helpers/get-parameter-schema"
25+
import crypto from "crypto"
2526

2627
const DEFAULT_RESPONSE_KEY = "default"
2728

@@ -859,3 +860,26 @@ export function paramToValue(param, paramValues) {
859860

860861
return values[0]
861862
}
863+
864+
// adapted from https://auth0.com/docs/flows/guides/auth-code-pkce/includes/create-code-verifier
865+
export function generateCodeVerifier() {
866+
return toBase64UrlEncoded(
867+
crypto.randomBytes(32)
868+
.toString("base64")
869+
)
870+
}
871+
872+
export function createCodeChallenge(codeVerifier) {
873+
return toBase64UrlEncoded(
874+
crypto.createHash("sha256")
875+
.update(codeVerifier, "ascii")
876+
.digest("base64")
877+
)
878+
}
879+
880+
function toBase64UrlEncoded(str) {
881+
return str
882+
.replace(/\+/g, "-")
883+
.replace(/\//g, "_")
884+
.replace(/=/g, "")
885+
}

‎test/mocha/core/oauth2-authorize.js

+44-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/* eslint-env mocha */
2-
import expect, { createSpy } from "expect"
3-
import { fromJS } from "immutable"
2+
import expect, { spyOn } from "expect"
43
import win from "core/window"
54
import oauth2Authorize from "core/oauth2-authorize"
5+
import * as utils from "core/utils"
66

77
describe("oauth2", function () {
88

@@ -20,20 +20,55 @@ describe("oauth2", function () {
2020
}
2121

2222
describe("authorize redirect", function () {
23-
2423
it("should build authorize url", function() {
25-
win.open = createSpy()
24+
const windowOpenSpy = spyOn(win, "open")
2625
oauth2Authorize(authConfig)
27-
expect(win.open.calls.length).toEqual(1)
28-
expect(win.open.calls[0].arguments[0]).toMatch("https://testAuthorizationUrl?response_type=code&redirect_uri=&state=")
26+
expect(windowOpenSpy.calls.length).toEqual(1)
27+
expect(windowOpenSpy.calls[0].arguments[0]).toMatch("https://testAuthorizationUrl?response_type=code&redirect_uri=&state=")
28+
29+
windowOpenSpy.restore()
2930
})
3031

3132
it("should append query parameters to authorizeUrl with query parameters", function() {
32-
win.open = createSpy()
33+
const windowOpenSpy = spyOn(win, "open")
3334
mockSchema.authorizationUrl = "https://testAuthorizationUrl?param=1"
3435
oauth2Authorize(authConfig)
35-
expect(win.open.calls.length).toEqual(1)
36-
expect(win.open.calls[0].arguments[0]).toMatch("https://testAuthorizationUrl?param=1&response_type=code&redirect_uri=&state=")
36+
expect(windowOpenSpy.calls.length).toEqual(1)
37+
expect(windowOpenSpy.calls[0].arguments[0]).toMatch("https://testAuthorizationUrl?param=1&response_type=code&redirect_uri=&state=")
38+
39+
windowOpenSpy.restore()
3740
})
41+
42+
it("should send code_challenge when using authorizationCode flow with usePkceWithAuthorizationCodeGrant enabled", function () {
43+
const windowOpenSpy = spyOn(win, "open")
44+
mockSchema.flow = "authorizationCode"
45+
46+
const expectedCodeVerifier = "mock_code_verifier"
47+
const expectedCodeChallenge = "mock_code_challenge"
48+
49+
const generateCodeVerifierSpy = spyOn(utils, "generateCodeVerifier").andReturn(expectedCodeVerifier)
50+
const createCodeChallengeSpy = spyOn(utils, "createCodeChallenge").andReturn(expectedCodeChallenge)
51+
52+
authConfig.authConfigs.usePkceWithAuthorizationCodeGrant = true
53+
54+
oauth2Authorize(authConfig)
55+
expect(win.open.calls.length).toEqual(1)
56+
57+
const actualUrl = new URLSearchParams(win.open.calls[0].arguments[0])
58+
expect(actualUrl.get("code_challenge")).toBe(expectedCodeChallenge)
59+
expect(actualUrl.get("code_challenge_method")).toBe("S256")
60+
61+
expect(createCodeChallengeSpy.calls.length).toEqual(1)
62+
expect(createCodeChallengeSpy.calls[0].arguments[0]).toBe(expectedCodeVerifier)
63+
64+
// The code_verifier should be stored to be able to send in
65+
// on the TokenUrl call
66+
expect(authConfig.auth.codeVerifier).toBe(expectedCodeVerifier)
67+
68+
// Restore spies
69+
windowOpenSpy.restore()
70+
generateCodeVerifierSpy.restore()
71+
createCodeChallengeSpy.restore()
72+
})
3873
})
3974
})

‎test/mocha/core/plugins/auth/actions.js

+29-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/* eslint-env mocha */
22
import expect, { createSpy } from "expect"
3-
import { authorizeRequest } from "corePlugins/auth/actions"
3+
import {
4+
authorizeRequest,
5+
authorizeAccessCodeWithFormParams,
6+
} from "corePlugins/auth/actions"
47

58
describe("auth plugin - actions", () => {
69

@@ -144,4 +147,29 @@ describe("auth plugin - actions", () => {
144147
.toEqual("http://google.com/authorize?q=1&myCustomParam=abc123")
145148
})
146149
})
150+
151+
describe("tokenRequest", function() {
152+
it("should send the code verifier when set", () => {
153+
const data = {
154+
auth: {
155+
schema: {
156+
get: () => "http://tokenUrl"
157+
},
158+
codeVerifier: "mock_code_verifier"
159+
},
160+
redirectUrl: "http://google.com"
161+
}
162+
163+
const authActions = {
164+
authorizeRequest: createSpy()
165+
}
166+
167+
authorizeAccessCodeWithFormParams(data)({ authActions })
168+
169+
expect(authActions.authorizeRequest.calls.length).toEqual(1)
170+
const actualArgument = authActions.authorizeRequest.calls[0].arguments[0]
171+
expect(actualArgument.body).toContain("code_verifier=" + data.auth.codeVerifier)
172+
expect(actualArgument.body).toContain("grant_type=authorization_code")
173+
})
174+
})
147175
})

‎test/mocha/core/system/system.jsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -916,8 +916,8 @@ describe("bound system", function(){
916916
describe("components", function() {
917917
it("should catch errors thrown inside of React Component Class render methods", function() {
918918
// Given
919-
// eslint-disable-next-line react/require-render-return
920919
class BrokenComponent extends React.Component {
920+
// eslint-disable-next-line react/require-render-return
921921
render() {
922922
throw new Error("This component is broken")
923923
}
@@ -943,8 +943,8 @@ describe("bound system", function(){
943943

944944
it("should catch errors thrown inside of pure component render methods", function() {
945945
// Given
946-
// eslint-disable-next-line react/require-render-return
947946
class BrokenComponent extends PureComponent {
947+
// eslint-disable-next-line react/require-render-return
948948
render() {
949949
throw new Error("This component is broken")
950950
}
@@ -994,8 +994,8 @@ describe("bound system", function(){
994994

995995
it("should catch errors thrown inside of container components", function() {
996996
// Given
997-
// eslint-disable-next-line react/require-render-return
998997
class BrokenComponent extends React.Component {
998+
// eslint-disable-next-line react/require-render-return
999999
render() {
10001000
throw new Error("This component is broken")
10011001
}

‎test/mocha/core/utils.js

+25
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
getSampleSchema,
2929
paramToIdentifier,
3030
paramToValue,
31+
generateCodeVerifier,
32+
createCodeChallenge,
3133
} from "core/utils"
3234
import win from "core/window"
3335

@@ -1402,4 +1404,27 @@ describe("utils", function() {
14021404
expect(res).toEqual("asdf")
14031405
})
14041406
})
1407+
1408+
describe("generateCodeVerifier", function() {
1409+
it("should generate a value of at least 43 characters", () => {
1410+
const codeVerifier = generateCodeVerifier()
1411+
1412+
// Source: https://tools.ietf.org/html/rfc7636#section-4.1
1413+
expect(codeVerifier.length).toBeGreaterThanOrEqualTo(43)
1414+
})
1415+
})
1416+
1417+
describe("createCodeChallenge", function() {
1418+
it("should hash the input using SHA256 and output the base64 url encoded value", () => {
1419+
// The `codeVerifier` has been randomly generated
1420+
const codeVerifier = "cY8OJ9MKvZ7hxQeIyRYD7KFmKA5znSFJ2rELysvy2UI"
1421+
1422+
// This value is the `codeVerifier` hashed using SHA256, which has been
1423+
// encoded using base64 url format.
1424+
// Source: https://tools.ietf.org/html/rfc7636#section-4.2
1425+
const expectedCodeChallenge = "LD9lx2p2PbvGkojuJy7-Elex7RnckzmqR7oIXjd4u84"
1426+
1427+
expect(createCodeChallenge(codeVerifier)).toBe(expectedCodeChallenge)
1428+
})
1429+
})
14051430
})

‎test/mocha/docker/oauth.js

+5
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe("docker: env translator - oauth block", function() {
2323
OAUTH_APP_NAME: ``,
2424
OAUTH_SCOPE_SEPARATOR: "",
2525
OAUTH_ADDITIONAL_PARAMS: ``,
26+
OAUTH_USE_PKCE: false
2627
}
2728

2829
expect(oauthBlockBuilder(input)).toEqual(dedent(`
@@ -33,8 +34,10 @@ describe("docker: env translator - oauth block", function() {
3334
appName: "",
3435
scopeSeparator: "",
3536
additionalQueryStringParams: undefined,
37+
usePkceWithAuthorizationCodeGrant: false,
3638
})`))
3739
})
40+
3841
it("should generate a full block", function() {
3942
const input = {
4043
OAUTH_CLIENT_ID: `myId`,
@@ -43,6 +46,7 @@ describe("docker: env translator - oauth block", function() {
4346
OAUTH_APP_NAME: `myAppName`,
4447
OAUTH_SCOPE_SEPARATOR: "%21",
4548
OAUTH_ADDITIONAL_PARAMS: `{ "a": 1234, "b": "stuff" }`,
49+
OAUTH_USE_PKCE: true
4650
}
4751

4852
expect(oauthBlockBuilder(input)).toEqual(dedent(`
@@ -53,6 +57,7 @@ describe("docker: env translator - oauth block", function() {
5357
appName: "myAppName",
5458
scopeSeparator: "%21",
5559
additionalQueryStringParams: { "a": 1234, "b": "stuff" },
60+
usePkceWithAuthorizationCodeGrant: true,
5661
})`))
5762
})
5863
})

0 commit comments

Comments
 (0)
Please sign in to comment.