├── .gitignore ├── .mocharc.json ├── Dockerfile ├── LICENSE ├── README.md ├── doc ├── Architecture.md ├── Setup.md ├── api-tests.png └── logical-components.png ├── package-lock.json ├── package.json ├── src ├── config.ts ├── controller │ ├── ClaimsController.ts │ ├── LoginController.ts │ ├── LogoutController.ts │ ├── RefreshTokenController.ts │ ├── UserInfoController.ts │ └── index.ts ├── lib │ ├── authorizationRequestData.ts │ ├── clientOptions.ts │ ├── cookieBuilder.ts │ ├── cookieEncrypter.ts │ ├── cookieName.ts │ ├── exceptions │ │ ├── AuthorizationClientException.ts │ │ ├── AuthorizationResponseException.ts │ │ ├── AuthorizationServerException.ts │ │ ├── CookieDecryptionException.ts │ │ ├── InvalidCookieException.ts │ │ ├── InvalidIDTokenException.ts │ │ ├── InvalidStateException.ts │ │ ├── MissingCodeVerifierException.ts │ │ ├── OAuthAgentException.ts │ │ ├── UnauthorizedException.ts │ │ ├── UnhandledException.ts │ │ └── index.ts │ ├── extraParams.ts │ ├── getIDTokenClaims.ts │ ├── getLogoutURL.ts │ ├── getToken.ts │ ├── getUserInfo.ts │ ├── grant.ts │ ├── idTokenValidator.ts │ ├── index.ts │ ├── loginHandler.ts │ ├── oauthAgentConfiguration.ts │ ├── pkce.ts │ ├── redirectUri.ts │ └── validateRequest.ts ├── middleware │ ├── exceptionMiddleware.ts │ ├── loggingMiddleware.ts │ └── requestLog.ts ├── server.ts └── validateExpressRequest.ts ├── test ├── end-to-end │ ├── idsvr │ │ ├── config-backup.xml │ │ ├── data-backup.sql │ │ ├── deploy.sh │ │ ├── docker-compose.yml │ │ ├── pre-commit │ │ └── teardown.sh │ ├── login.sh │ └── test-oauth-agent.sh └── integration │ ├── claimsControllerTests.ts │ ├── extensibilityTests.ts │ ├── loginControllerTests.ts │ ├── logoutControllerTests.ts │ ├── mappings │ ├── authorization-code-grant-mapping.json │ └── userinfo-mapping.json │ ├── refreshTokenControllerTests.ts │ ├── responses.ts │ ├── testUtils.ts │ └── userInfoControllerTests.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | license.json 2 | node_modules 3 | dist 4 | data 5 | .DS_Store -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "node-option": ["import=tsx"], 4 | "spec": ["test/integration/**/*.ts"] 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bookworm-slim 2 | 3 | RUN groupadd --gid 10000 apiuser \ 4 | && useradd --uid 10001 --gid apiuser --shell /bin/bash --create-home apiuser 5 | 6 | WORKDIR /usr/oauth-agent 7 | COPY --chown=10001:10000 dist /usr/oauth-agent/dist 8 | COPY --chown=10001:10000 package*.json /usr/oauth-agent/ 9 | 10 | RUN npm install --omit=dev 11 | 12 | USER 10001 13 | 14 | CMD ["node", "dist/server.js"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Curity AB 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Node.js OAuth Agent for SPAs 2 | 3 | [![Quality](https://img.shields.io/badge/quality-test-yellow)](https://curity.io/resources/code-examples/status/) 4 | [![Availability](https://img.shields.io/badge/availability-source-blue)](https://curity.io/resources/code-examples/status/) 5 | 6 | ## Overview 7 | 8 | The OAuth Agent acts as a modern `Back End for Front End (BFF)` for Single Page Applications.\ 9 | This implementation demonstrates the standard pattern for SPAs: 10 | 11 | - Strongest browser security with only `SameSite=strict` cookies 12 | - The OpenID Connect flow uses Authorization Code Flow (PKCE) and a client secret 13 | 14 | ![Logical Components](/doc/logical-components.png) 15 | 16 | ## Architecture 17 | 18 | The following endpoints are implemented by the OAuth agent.\ 19 | The SPA calls these endpoints via one liners, to perform its OAuth work: 20 | 21 | | Endpoint | Description | 22 | | -------- | ----------- | 23 | | POST /oauth-agent/login/start | Start a login by providing the request URL to the SPA and setting temporary cookies | 24 | | POST /oauth-agent/login/end | Complete a login and issuing secure cookies for the SPA containing encrypted tokens | 25 | | GET /oauth-agent/userInfo | Return information from the User Info endpoint for the SPA to display | 26 | | GET /oauth-agent/claims | Return ID token claims such as `auth_time` and `acr` | 27 | | POST /oauth-agent/refresh | Refresh an access token and rewrite cookies | 28 | | POST /oauth-agent/logout | Clear cookies and return an end session request URL | 29 | 30 | For further details see the [Architecture](/doc/Architecture.md) article. 31 | 32 | ## Deployment 33 | 34 | Build the OAuth agent into a Docker image: 35 | 36 | ```bash 37 | npm install 38 | npm run build 39 | docker build -t oauthagent:1.0.0 . 40 | ``` 41 | 42 | Then deploy the Docker image with environment variables similar to these: 43 | 44 | ```yaml 45 | oauth-agent: 46 | image: oauthagent:1.0.0 47 | hostname: oauthagent-host 48 | environment: 49 | PORT: 3001 50 | TRUSTED_WEB_ORIGIN: 'https://www.example.com' 51 | ISSUER: 'https://login.example.com/oauth/v2/oauth-anonymous' 52 | AUTHORIZE_ENDPOINT: 'https://login.example.com/oauth/v2/oauth-authorize' 53 | TOKEN_ENDPOINT: 'https://login-internal/oauth/v2/oauth-token' 54 | USERINFO_ENDPOINT: 'https://login-internal/oauth/v2/oauth-userinfo' 55 | LOGOUT_ENDPOINT: 'https://login.example.com/oauth/v2/oauth-session/logout' 56 | CLIENT_ID: 'spa-client' 57 | CLIENT_SECRET: 'Password1' 58 | REDIRECT_URI: 'https://www.example.com/' 59 | POST_LOGOUT_REDIRECT_URI: 'https:www.example.com/' 60 | SCOPE: 'openid profile' 61 | COOKIE_DOMAIN: 'api.example.com' 62 | COOKIE_NAME_PREFIX: 'example' 63 | COOKIE_ENCRYPTION_KEY: 'fda91643fce9af565bdc34cd965b48da75d1f5bd8846bf0910dd6d7b10f06dfe' 64 | CORS_ENABLED: 'true' 65 | SERVER_CERT_P12_PATH: '/certs/my.p12' 66 | SERVER_CERT_P12_PASSWORD: 'Password1' 67 | ``` 68 | 69 | If the OAuth Agent is deployed to the web domain, then set these properties: 70 | 71 | ```yaml 72 | COOKIE_DOMAIN: 'www.example.com' 73 | CORS_ENABLED: 'false' 74 | ``` 75 | 76 | In development setups, HTTP URLs can be used and certificate values left blank. 77 | 78 | ## OAuth Agent Development 79 | 80 | See the [Setup](/doc/Setup.md) article for details on productive OAuth Agent development.\ 81 | This enables a test driven approach to developing the OAuth Agent, without the need for a browser. 82 | 83 | ## End-to-End SPA Flow 84 | 85 | Run the below code example to use the OAuth Agent in an end-to-end SPA flow: 86 | 87 | - [SPA Code Example](https://github.com/curityio/spa-using-token-handler) 88 | 89 | ## Website Documentation 90 | 91 | See the [Curity Token Handler Design Overview](https://curity.io/resources/learn/token-handler-overview/) for further token handler information. 92 | 93 | ## More Information 94 | 95 | Please visit [curity.io](https://curity.io/) for more information about the Curity Identity Server. 96 | -------------------------------------------------------------------------------- /doc/Architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture of the OAuth Agent 2 | 3 | ## Overview 4 | 5 | Node and the Express framework are used to build the OAuth Agent. The API can be deployed to a host of your choice. The API handles token responses from an Authorization Server, then saves encrypted tokens in http-only cookies. The API is therefore stateless and easy to manage, and does not require a database. The SPA can then use secure cookies to call business APIs, or to get userinfo from this API. 6 | 7 | ## Endpoints 8 | 9 | The API exposes the following endpoints to the SPA: 10 | 11 | 1. POST `/oauth-agent/login/start` 12 | 2. POST `/oauth-agent/login/end` 13 | 3. GET `/oauth-agent/userInfo` 14 | 4. GET `/oauth-agent/claims` 15 | 5. POST `/oauth-agent/refresh` 16 | 6. POST `/oauth-agent/logout` 17 | 18 | ### POST `/login/start` 19 | 20 | This endpoint is used to initialize an authorization request. The API responds with a URL which the SPA should navigate to in order to start the authorization flow at the Authorization Server. The URL returned can contain query parameters or be a JAR or PAR URL. However, the format of the URL is irrelevant to the SPA, it should just redirect the user to that URL. 21 | 22 | The API responds with a JSON containing the `authorizationRequestUrl` field. 23 | 24 | #### Example request 25 | 26 | `POST https://api.example.com/oauth-agent/login/start` 27 | 28 | Response: 29 | ```json 30 | { 31 | "authorizationRequestUrl": "https://login.example.com/oauth/authorize?client_id=spa-client&response_type=code&scope=openid%20read&redirect_uri=https://www.example.com/" 32 | } 33 | ``` 34 | 35 | If required, the SPA can POST an object with an extra params field containing runtime OpenID Connect parameters.\ 36 | They key and value of each item must be strings and they will then be appended to the request URL. 37 | 38 | ```json 39 | { 40 | "extraParams": [ 41 | { 42 | "key": "max-age", 43 | "value": "3600" 44 | }, 45 | { 46 | "key": "ui_locales", 47 | "value": "fr" 48 | } 49 | ] 50 | } 51 | ``` 52 | 53 | ### POST `/login/end` 54 | 55 | This endpoint should be be called by the SPA on any page load. The SPA sends the current URL to the API, which can either finish the authorization flow (if it was a response from the Authorization Server), or inform the SPA whether the user is logged in or not (based on the presence of secure cookies). 56 | 57 | #### Example request 58 | 59 | ```text 60 | POST https://api.example.com/oauth-agent/login/end 61 | ?pageUrl=http://www.example.com?code=abcdef&state=qwerty 62 | ``` 63 | 64 | The response will contain a few `Set-Cookie` headers. 65 | 66 | ### GET `/userInfo` 67 | 68 | Endpoint which sends the access token to the user info endpoint, then returns data. 69 | 70 | #### Example 71 | 72 | ```text 73 | GET https://api.example.com/oauth-agent/userInfo 74 | Cookie: example-at=2558e7806c0523fd96d105... 75 | ``` 76 | 77 | Response 78 | 79 | ```json 80 | { 81 | "sub": "0abd0b16b309a3a034af8494aa0092aa42813e635f194c795df5006db90743e8", 82 | "preferred_username": "demouser", 83 | "given_name": "Demo", 84 | "updated_at": 1627313147, 85 | "family_name": "User" 86 | } 87 | ``` 88 | 89 | ### GET `/claims` 90 | 91 | Endpoint which returns claims of the ID token contained in the session cookie. 92 | 93 | #### Example 94 | 95 | ```text 96 | GET https://api.example.com/oauth-agent/claims 97 | Cookie: example-id=2558e7806c0523fd96d105... 98 | ``` 99 | 100 | Response 101 | 102 | ```json 103 | { 104 | "exp":1626263589, 105 | "nbf":1626259989, 106 | "jti":"34e76304-0bc3-46ee-bc70-e21685eb5282", 107 | "iss":"https://login.example.com/oauth", 108 | "aud":"spa-client", 109 | "sub":"0abd0b16b309a3a034af8494aa0092aa42813e635f194c795df5006db90743e8", 110 | "auth_time":1626259937, 111 | "iat":1626259989 112 | } 113 | ``` 114 | 115 | ### POST `/refresh` 116 | 117 | This endpoint can be called to force the API to refresh the access token. If the API is able to perform the refresh new cookies will be set in the response (which is a 204 response), otherwise the API will respond with a 401 response (e.g. when the refresh token is expired) to inform the SPA that a new login is required. 118 | 119 | ### POST `/logout` 120 | 121 | This endpoint can be called to get a logout URL. The SPA should navigate the user to that URL in order to perform a logout in the Authorization Server. The API also sets empty session cookies in the response. 122 | -------------------------------------------------------------------------------- /doc/Setup.md: -------------------------------------------------------------------------------- 1 | # How to Run the OAuth Agent Locally 2 | 3 | Follow the below steps to get set up for developing and testing the OAuth Agent itself. 4 | 5 | ## Prerequisites 6 | 7 | Ensure that these tools are installed locally: 8 | 9 | - [Node.js 20+](https://nodejs.org/en/download/) 10 | - [Docker](https://www.docker.com/products/docker-desktop) 11 | - [jq](https://stedolan.github.io/jq/download/) 12 | 13 | Also get a license file for the Curity Identity Server: 14 | 15 | - Sign in to the [Curity Developer Portal](https://developer.curity.io/) with your Github account. 16 | - You can get a [Free Community Edition License](https://curity.io/product/community/) if you are new to the Curity Identity Server. 17 | 18 | ## Update your Hosts File 19 | 20 | Ensure that the hosts file contains the following development domain names: 21 | 22 | ```text 23 | 127.0.0.1 api.example.local login.example.local 24 | :1 localhost 25 | ``` 26 | 27 | ## Understand URLs 28 | 29 | For local development of the OAuth Agent the following URLs are used, with HTTP to reduce development infrastructure: 30 | 31 | | Component | Base URL | Usage | 32 | | --------- | -------- | ----- | 33 | | OAuth Agent | http://api.example.local:8080/oauth-agent | This acts as a Back End for Front End for SPAs | 34 | | Curity Identity Server | http://login.example.local:8443 | This will receive a string client secret from the OAuth Agent | 35 | 36 | ## Build and Run the OAuth Agent 37 | 38 | Run these commands from the root folder and the API will then listen on HTTP over port 8080: 39 | 40 | ```bash 41 | npm install 42 | npm start 43 | ``` 44 | 45 | Test that the API is contactable by running this command from the root folder: 46 | 47 | ```bash 48 | curl -X POST http://api.example.local:8080/oauth-agent/login/start \ 49 | -H "origin: http://www.example.local" | jq 50 | ``` 51 | 52 | ## Run Integration Tests 53 | 54 | Run some tests that require only a running OAuth Agent, with a mocked Identity Server: 55 | 56 | ```bash 57 | npm run wiremock 58 | npm test 59 | ``` 60 | 61 | ## Run End-to-End Tests 62 | 63 | Run some tests that also use the Curity Identity Server.\ 64 | First copy a `license.json` file into the `test/end-to-end/idsvr` folder and then run the following command: 65 | 66 | ```bash 67 | ./test/end-to-end/idsvr/deploy.sh 68 | ``` 69 | 70 | Ensure that the OAuth Agent is running: 71 | 72 | ```bash 73 | npm start 74 | ``` 75 | 76 | Then run a test script that uses curl requests to verify the OAuth Agent's operations: 77 | 78 | ```bash 79 | ./test/end-to-end/test-oauth-agent.sh 80 | ``` 81 | 82 | ![API Tests](api-tests.png) 83 | 84 | ## Free Docker Resources 85 | 86 | When finished with your development session, free Docker resources like this: 87 | 88 | ```bash 89 | ./test/idsvr/teardown.sh 90 | ``` 91 | -------------------------------------------------------------------------------- /doc/api-tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curityio/oauth-agent-node-express/4293863272eb1cbccfa52cab36939d44ba73a37c/doc/api-tests.png -------------------------------------------------------------------------------- /doc/logical-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curityio/oauth-agent-node-express/4293863272eb1cbccfa52cab36939d44ba73a37c/doc/logical-components.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-agent-node-express", 3 | "version": "1.6.0", 4 | "description": "An OAuth Agent that runs on the Express web server", 5 | "scripts": { 6 | "start": "tsx --no-warnings src/server.ts", 7 | "wiremock": "wiremock --root-dir test/integration --port 8443", 8 | "test": "mocha", 9 | "build": "rm -rf dist && tsc" 10 | }, 11 | "author": "Curity AB", 12 | "license": "Apache-2.0", 13 | "type": "module", 14 | "engines": { 15 | "node": ">=20" 16 | }, 17 | "dependencies": { 18 | "base64url": "^3.0.1", 19 | "cookie": "^1.0.1", 20 | "cookie-parser": "^1.4.6", 21 | "cors": "^2.8.5", 22 | "express": "^4.21.2", 23 | "jose": "^6.0.10", 24 | "node-fetch": "^3.3.2", 25 | "url-parse": "^1.5.10" 26 | }, 27 | "devDependencies": { 28 | "@types/chai": "^4.3.11", 29 | "@types/cookie": "^0.6.0", 30 | "@types/cookie-parser": "^1.4.6", 31 | "@types/cors": "^2.8.17", 32 | "@types/express": "^4.17.21", 33 | "@types/mocha": "^10.0.6", 34 | "@types/node-fetch": "^2.6.9", 35 | "@types/set-cookie-parser": "^2.4.7", 36 | "@types/url-parse": "^1.4.11", 37 | "chai": "^4.3.10", 38 | "mocha": "^10.5.2", 39 | "set-cookie-parser": "^2.6.0", 40 | "tsx": "^4.19.3", 41 | "typescript": "^5.8.3", 42 | "wiremock": "^3.8.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {OAuthAgentConfiguration} from './lib/index.js' 18 | import {SerializeOptions} from 'cookie' 19 | 20 | const useSsl = !!process.env.SERVER_CERT_P12_PATH; 21 | 22 | export const config: OAuthAgentConfiguration = { 23 | 24 | // Host settings 25 | port: process.env.PORT || '8080', 26 | endpointsPrefix: '/oauth-agent', 27 | serverCertPath: process.env.SERVER_CERT_P12_PATH || '', 28 | serverCertPassword: process.env.SERVER_CERT_P12_PASSWORD || '', 29 | 30 | // Client settings 31 | clientID: process.env.CLIENT_ID || 'spa-client', 32 | clientSecret: process.env.CLIENT_SECRET || 'Password1', 33 | redirectUri: process.env.REDIRECT_URI || 'http://www.example.local/', 34 | postLogoutRedirectURI: process.env.POST_LOGOUT_REDIRECT_URI || 'http://www.example.local/', 35 | scope: process.env.SCOPE || 'openid profile', 36 | 37 | // Cookie related settings 38 | cookieNamePrefix: process.env.COOKIE_NAME_PREFIX || 'example', 39 | encKey: process.env.COOKIE_ENCRYPTION_KEY || '4e4636356d65563e4c73233847503e3b21436e6f7629724950526f4b5e2e4e50', 40 | trustedWebOrigins: [process.env.TRUSTED_WEB_ORIGIN || 'http://www.example.local'], 41 | corsEnabled: process.env.CORS_ENABLED ? process.env.CORS_ENABLED === 'true' : true, 42 | cookieOptions: { 43 | httpOnly: true, 44 | sameSite: true, 45 | secure: useSsl, 46 | domain: process.env.COOKIE_DOMAIN || 'api.example.local', 47 | path: process.env.COOKIE_BASE_PATH || '/', 48 | } as SerializeOptions, 49 | 50 | // Authorization Server settings 51 | issuer: process.env.ISSUER || 'http://login.example.local:8443/oauth/v2/oauth-anonymous', 52 | authorizeEndpoint: process.env.AUTHORIZE_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-authorize', 53 | logoutEndpoint: process.env.LOGOUT_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-session/logout', 54 | tokenEndpoint: process.env.TOKEN_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-token', 55 | userInfoEndpoint: process.env.USERINFO_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-userinfo', 56 | } 57 | -------------------------------------------------------------------------------- /src/controller/ClaimsController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import {getIDCookieName, getIDTokenClaims, ValidateRequestOptions} from '../lib/index.js' 19 | import {config} from '../config.js' 20 | import validateExpressRequest from '../validateExpressRequest.js' 21 | import {InvalidCookieException} from '../lib/exceptions/index.js' 22 | import {asyncCatch} from '../middleware/exceptionMiddleware.js'; 23 | 24 | class ClaimsController { 25 | public router = express.Router() 26 | 27 | constructor() { 28 | this.router.get('/', asyncCatch(this.getClaims)) 29 | } 30 | 31 | getClaims = async (req: express.Request, res: express.Response, next: express.NextFunction) => { 32 | 33 | // Verify the web origin 34 | const options = new ValidateRequestOptions() 35 | options.requireCsrfHeader = false; 36 | options.requireTrustedOrigin = config.corsEnabled; 37 | validateExpressRequest(req, options) 38 | 39 | const idTokenCookieName = getIDCookieName(config.cookieNamePrefix) 40 | if (req.cookies && req.cookies[idTokenCookieName]) { 41 | 42 | const userData = getIDTokenClaims(config.encKey, req.cookies[idTokenCookieName]) 43 | res.status(200).json(userData) 44 | 45 | } else { 46 | const error = new InvalidCookieException() 47 | error.logInfo = 'No ID cookie was supplied in a call to get claims' 48 | throw error 49 | } 50 | } 51 | } 52 | 53 | export default ClaimsController 54 | -------------------------------------------------------------------------------- /src/controller/LoginController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import { 19 | ValidateRequestOptions, 20 | createAuthorizationRequest, 21 | handleAuthorizationResponse, 22 | validateIDtoken, 23 | decryptCookie, 24 | getCSRFCookieName, 25 | getTokenEndpointResponse, 26 | getTempLoginDataCookie, 27 | getTempLoginDataCookieName, 28 | getCookiesForTokenResponse, 29 | getATCookieName, 30 | generateRandomString, 31 | } from '../lib/index.js' 32 | import {config} from '../config.js' 33 | import validateExpressRequest from '../validateExpressRequest.js' 34 | import {asyncCatch} from '../middleware/exceptionMiddleware.js'; 35 | 36 | class LoginController { 37 | public router = express.Router() 38 | 39 | constructor() { 40 | this.router.post('/start', asyncCatch(this.startLogin)) 41 | this.router.post('/end', asyncCatch(this.handlePageLoad)) 42 | } 43 | 44 | /* 45 | * The SPA calls this endpoint to ask the OAuth Agent for the authorization request URL 46 | */ 47 | startLogin = async (req: express.Request, res: express.Response) => { 48 | 49 | const options = new ValidateRequestOptions() 50 | options.requireCsrfHeader = false 51 | validateExpressRequest(req, options) 52 | 53 | const authorizationRequestData = createAuthorizationRequest(config, req.body) 54 | 55 | res.setHeader('Set-Cookie', 56 | getTempLoginDataCookie(authorizationRequestData.codeVerifier, authorizationRequestData.state, config.cookieOptions, config.cookieNamePrefix, config.encKey)) 57 | res.status(200).json({ 58 | authorizationRequestUrl: authorizationRequestData.authorizationRequestURL 59 | }) 60 | } 61 | 62 | /* 63 | * The SPA posts its URL here on every page load, to get its authenticated state 64 | * When an OAuth response is received it is handled and cookies are written 65 | */ 66 | handlePageLoad = async (req: express.Request, res: express.Response, next: express.NextFunction) => { 67 | 68 | const options = new ValidateRequestOptions() 69 | options.requireCsrfHeader = false 70 | validateExpressRequest(req, options) 71 | 72 | const data = await handleAuthorizationResponse(req.body?.pageUrl) 73 | 74 | let isLoggedIn = false 75 | let handled = false 76 | let csrfToken: string = '' 77 | 78 | if (data.code && data.state) { 79 | 80 | const tempLoginData = req.cookies ? req.cookies[getTempLoginDataCookieName(config.cookieNamePrefix)] : undefined 81 | 82 | const tokenResponse = await getTokenEndpointResponse(config, data.code, data.state, tempLoginData) 83 | if (tokenResponse.id_token) { 84 | validateIDtoken(config, tokenResponse.id_token) 85 | } 86 | 87 | csrfToken = generateRandomString() 88 | const csrfCookie = req.cookies[getCSRFCookieName(config.cookieNamePrefix)] 89 | if (csrfCookie) { 90 | 91 | try { 92 | // Avoid setting a new value if the user opens two browser tabs and signs in on both 93 | csrfToken = decryptCookie(config.encKey, csrfCookie) 94 | 95 | } catch (e) { 96 | 97 | // If the system has been redeployed with a new cookie encryption key, decrypting old cookies from the browser will fail 98 | // In this case generate a new CSRF token so that the SPA can complete its login without errors 99 | csrfToken = generateRandomString() 100 | } 101 | } else { 102 | 103 | // Generate a new value otherwise 104 | csrfToken = generateRandomString() 105 | } 106 | 107 | const cookiesToSet = getCookiesForTokenResponse(tokenResponse, config, true, csrfToken) 108 | res.set('Set-Cookie', cookiesToSet) 109 | handled = true 110 | isLoggedIn = true 111 | 112 | } else { 113 | 114 | // During a page reload, return the existing anti forgery token 115 | isLoggedIn = !!(req.cookies && req.cookies[getATCookieName(config.cookieNamePrefix)]) 116 | if (isLoggedIn) { 117 | 118 | csrfToken = decryptCookie(config.encKey, req.cookies[getCSRFCookieName(config.cookieNamePrefix)]) 119 | } 120 | } 121 | 122 | const responseBody = { 123 | handled, 124 | isLoggedIn, 125 | } as any 126 | 127 | if (csrfToken) { 128 | responseBody.csrf = csrfToken 129 | } 130 | 131 | res.status(200).json(responseBody) 132 | } 133 | } 134 | 135 | export default LoginController -------------------------------------------------------------------------------- /src/controller/LogoutController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import {config} from '../config.js' 19 | import {getATCookieName, getCookiesForUnset, getLogoutURL, ValidateRequestOptions} from '../lib/index.js' 20 | import {InvalidCookieException} from '../lib/exceptions/index.js' 21 | import validateExpressRequest from '../validateExpressRequest.js' 22 | import {asyncCatch} from '../middleware/exceptionMiddleware.js'; 23 | 24 | class LogoutController { 25 | public router = express.Router() 26 | 27 | constructor() { 28 | this.router.post('/', asyncCatch(this.logoutUser)) 29 | } 30 | 31 | logoutUser = async (req: express.Request, res: express.Response, next: express.NextFunction) => { 32 | 33 | // Check for an allowed origin and the presence of a CSRF token 34 | const options = new ValidateRequestOptions() 35 | validateExpressRequest(req, options) 36 | 37 | if (req.cookies && req.cookies[getATCookieName(config.cookieNamePrefix)]) { 38 | 39 | const logoutURL = getLogoutURL(config) 40 | res.setHeader('Set-Cookie', getCookiesForUnset(config.cookieOptions, config.cookieNamePrefix)) 41 | res.json({ url: logoutURL}) 42 | 43 | } else { 44 | const error = new InvalidCookieException() 45 | error.logInfo = 'No auth cookie was supplied in a logout call' 46 | throw error 47 | } 48 | } 49 | } 50 | 51 | export default LogoutController 52 | -------------------------------------------------------------------------------- /src/controller/RefreshTokenController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import {config} from '../config.js' 19 | import { 20 | decryptCookie, getAuthCookieName, getCookiesForTokenResponse, refreshAccessToken, validateIDtoken, ValidateRequestOptions 21 | } from '../lib/index.js' 22 | import {InvalidCookieException} from '../lib/exceptions/index.js' 23 | import validateExpressRequest from '../validateExpressRequest.js' 24 | import {asyncCatch} from '../middleware/exceptionMiddleware.js'; 25 | 26 | class RefreshTokenController { 27 | public router = express.Router() 28 | 29 | constructor() { 30 | this.router.post('/', asyncCatch(this.RefreshTokenFromCookie)) 31 | } 32 | 33 | RefreshTokenFromCookie = async (req: express.Request, res: express.Response, next: express.NextFunction) => { 34 | 35 | // Check for an allowed origin and the presence of a CSRF token 36 | const options = new ValidateRequestOptions() 37 | validateExpressRequest(req, options) 38 | 39 | const authCookieName = getAuthCookieName(config.cookieNamePrefix) 40 | if (req.cookies && req.cookies[authCookieName]) { 41 | 42 | const refreshToken = decryptCookie(config.encKey, req.cookies[authCookieName]) 43 | 44 | const tokenResponse = await refreshAccessToken(refreshToken, config) 45 | if (tokenResponse.id_token) { 46 | validateIDtoken(config, tokenResponse.id_token) 47 | } 48 | 49 | const cookiesToSet = getCookiesForTokenResponse(tokenResponse, config) 50 | res.setHeader('Set-Cookie', cookiesToSet) 51 | res.status(204).send() 52 | 53 | } else { 54 | const error = new InvalidCookieException() 55 | error.logInfo = 'No auth cookie was supplied in a token refresh call' 56 | throw error 57 | } 58 | } 59 | } 60 | 61 | export default RefreshTokenController 62 | -------------------------------------------------------------------------------- /src/controller/UserInfoController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import {getATCookieName, getUserInfo, ValidateRequestOptions} from '../lib/index.js' 19 | import {config} from '../config.js' 20 | import validateExpressRequest from '../validateExpressRequest.js' 21 | import {InvalidCookieException} from '../lib/exceptions/index.js' 22 | import {asyncCatch} from '../middleware/exceptionMiddleware.js'; 23 | 24 | class UserInfoController { 25 | public router = express.Router() 26 | 27 | constructor() { 28 | this.router.get('/', asyncCatch(this.getUserInfo)) 29 | } 30 | 31 | getUserInfo = async (req: express.Request, res: express.Response, next: express.NextFunction) => { 32 | 33 | // Verify the web origin 34 | const options = new ValidateRequestOptions() 35 | options.requireCsrfHeader = false; 36 | options.requireTrustedOrigin = config.corsEnabled; 37 | validateExpressRequest(req, options) 38 | 39 | const atCookieName = getATCookieName(config.cookieNamePrefix) 40 | if (req.cookies && req.cookies[atCookieName]) { 41 | 42 | const accessToken = req.cookies[atCookieName] 43 | const userData = await getUserInfo(config, config.encKey, accessToken) 44 | res.status(200).json(userData) 45 | 46 | } else { 47 | const error = new InvalidCookieException() 48 | error.logInfo = 'No AT cookie was supplied in a call to get user info' 49 | throw error 50 | } 51 | } 52 | } 53 | 54 | export default UserInfoController 55 | -------------------------------------------------------------------------------- /src/controller/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import LoginController from './LoginController.js' 18 | import LogoutController from './LogoutController.js' 19 | import UserInfoController from './UserInfoController.js' 20 | import ClaimsController from './ClaimsController.js' 21 | import RefreshTokenController from './RefreshTokenController.js' 22 | 23 | export { 24 | LoginController, 25 | LogoutController, 26 | UserInfoController, 27 | ClaimsController, 28 | RefreshTokenController 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/authorizationRequestData.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export class AuthorizationRequestData { 18 | public readonly authorizationRequestURL: string 19 | public readonly codeVerifier: string 20 | public readonly state: string 21 | 22 | constructor(authorizationRequestURL: string, codeVerifier: string, state: string) { 23 | this.authorizationRequestURL = authorizationRequestURL 24 | this.codeVerifier = codeVerifier 25 | this.state = state 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/clientOptions.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {ExtraParam} from './extraParams.js'; 18 | 19 | export interface ClientOptions { 20 | extraParams: ExtraParam[]; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/cookieBuilder.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {SerializeOptions, serialize} from 'cookie' 18 | import {getEncryptedCookie} from './cookieEncrypter.js' 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js' 20 | import {getATCookieName, getAuthCookieName, getCSRFCookieName, getIDCookieName} from './cookieName.js' 21 | import {getTempLoginDataCookieForUnset} from './pkce.js' 22 | 23 | const DAY_MILLISECONDS = 1000 * 60 * 60 * 24 24 | 25 | function getCookiesForTokenResponse(tokenResponse: any, config: OAuthAgentConfiguration, unsetTempLoginDataCookie: boolean = false, csrfCookieValue?: string): string[] { 26 | 27 | const cookies = [ 28 | getEncryptedCookie(config.cookieOptions, tokenResponse.access_token, getATCookieName(config.cookieNamePrefix), config.encKey) 29 | ] 30 | 31 | if (csrfCookieValue) { 32 | cookies.push(getEncryptedCookie(config.cookieOptions, csrfCookieValue, getCSRFCookieName(config.cookieNamePrefix), config.encKey)) 33 | } 34 | 35 | if (unsetTempLoginDataCookie) { 36 | cookies.push(getTempLoginDataCookieForUnset(config.cookieOptions, config.cookieNamePrefix)) 37 | } 38 | 39 | if (tokenResponse.refresh_token) { 40 | const refreshTokenCookieOptions = { 41 | ...config.cookieOptions, 42 | path: config.endpointsPrefix + '/refresh' 43 | } 44 | cookies.push(getEncryptedCookie(refreshTokenCookieOptions, tokenResponse.refresh_token, getAuthCookieName(config.cookieNamePrefix), config.encKey)) 45 | } 46 | 47 | if (tokenResponse.id_token) { 48 | const idTokenCookieOptions = { 49 | ...config.cookieOptions, 50 | path: config.endpointsPrefix + '/claims' 51 | } 52 | cookies.push(getEncryptedCookie(idTokenCookieOptions, tokenResponse.id_token, getIDCookieName(config.cookieNamePrefix), config.encKey)) 53 | } 54 | 55 | return cookies 56 | } 57 | 58 | function getCookiesForUnset(options: SerializeOptions, cookieNamePrefix: string): string[] { 59 | 60 | const cookieOptions = { 61 | ...options, 62 | expires: new Date(Date.now() - DAY_MILLISECONDS), 63 | } 64 | 65 | return [ 66 | serialize(getAuthCookieName(cookieNamePrefix), "", cookieOptions), 67 | serialize(getATCookieName(cookieNamePrefix), "", cookieOptions), 68 | serialize(getIDCookieName(cookieNamePrefix), "", cookieOptions), 69 | serialize(getCSRFCookieName(cookieNamePrefix), "", cookieOptions) 70 | ] 71 | } 72 | 73 | export { getCookiesForTokenResponse, getCookiesForUnset }; -------------------------------------------------------------------------------- /src/lib/cookieEncrypter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import crypto from 'crypto' 18 | import base64url from 'base64url'; 19 | import {SerializeOptions, serialize} from 'cookie' 20 | import {CookieDecryptionException, InvalidCookieException} from '../lib/exceptions/index.js' 21 | 22 | const VERSION_SIZE = 1; 23 | const GCM_IV_SIZE = 12; 24 | const GCM_TAG_SIZE = 16; 25 | const CURRENT_VERSION = 1; 26 | 27 | function encryptCookie(encKeyHex: string, plaintext: string): string { 28 | 29 | const ivBytes = crypto.randomBytes(GCM_IV_SIZE) 30 | const encKeyBytes = Buffer.from(encKeyHex, "hex") 31 | 32 | const cipher = crypto.createCipheriv("aes-256-gcm", encKeyBytes, ivBytes) 33 | 34 | const encryptedBytes = cipher.update(plaintext) 35 | const finalBytes = cipher.final() 36 | 37 | const versionBytes = Buffer.from(new Uint8Array([CURRENT_VERSION])) 38 | const ciphertextBytes = Buffer.concat([encryptedBytes, finalBytes]) 39 | const tagBytes = cipher.getAuthTag() 40 | 41 | const allBytes = Buffer.concat([versionBytes, ivBytes, ciphertextBytes, tagBytes]) 42 | 43 | return base64url.encode(allBytes) 44 | } 45 | 46 | function decryptCookie(encKeyHex: string, encryptedbase64value: string): string { 47 | 48 | const allBytes = base64url.toBuffer(encryptedbase64value) 49 | 50 | const minSize = VERSION_SIZE + GCM_IV_SIZE + 1 + GCM_TAG_SIZE 51 | if (allBytes.length < minSize) { 52 | const error = new Error("The received cookie has an invalid length") 53 | throw new InvalidCookieException(error) 54 | } 55 | 56 | const version = allBytes[0] 57 | if (version != CURRENT_VERSION) { 58 | const error = new Error("The received cookie has an invalid format") 59 | throw new InvalidCookieException(error) 60 | } 61 | 62 | let offset = VERSION_SIZE 63 | const ivBytes = allBytes.slice(offset, offset + GCM_IV_SIZE) 64 | 65 | offset += GCM_IV_SIZE 66 | const ciphertextBytes = allBytes.slice(offset, allBytes.length - GCM_TAG_SIZE) 67 | 68 | offset = allBytes.length - GCM_TAG_SIZE 69 | const tagBytes = allBytes.slice(offset, allBytes.length) 70 | 71 | try { 72 | 73 | const encKeyBytes = Buffer.from(encKeyHex, "hex") 74 | const decipher = crypto.createDecipheriv('aes-256-gcm', encKeyBytes, ivBytes) 75 | decipher.setAuthTag(tagBytes) 76 | 77 | const decryptedBytes = decipher.update(ciphertextBytes) 78 | const finalBytes = decipher.final() 79 | 80 | const plaintextBytes = Buffer.concat([decryptedBytes, finalBytes]) 81 | return plaintextBytes.toString() 82 | 83 | } catch(e: any) { 84 | 85 | throw new CookieDecryptionException(e) 86 | } 87 | } 88 | 89 | function getEncryptedCookie(options: SerializeOptions, value: string, name: string, encKey: string): string { 90 | return serialize(name, encryptCookie(encKey, value), options) 91 | } 92 | 93 | export { getEncryptedCookie, decryptCookie, encryptCookie }; 94 | -------------------------------------------------------------------------------- /src/lib/cookieName.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const getTempLoginDataCookieName = (cookieNamePrefix: string): string => cookieNamePrefix + '-login' 18 | const getAuthCookieName = (cookieNamePrefix: string): string => cookieNamePrefix + '-auth' 19 | const getATCookieName = (cookieNamePrefix: string): string => cookieNamePrefix + '-at' 20 | const getIDCookieName = (cookieNamePrefix: string): string => cookieNamePrefix + '-id' 21 | const getCSRFCookieName = (cookieNamePrefix: string): string => cookieNamePrefix + '-csrf' 22 | 23 | export {getTempLoginDataCookieName, getATCookieName, getAuthCookieName, getIDCookieName, getCSRFCookieName} 24 | -------------------------------------------------------------------------------- /src/lib/exceptions/AuthorizationClientException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | import {Grant} from '../grant.js' 19 | 20 | export default class AuthorizationClientException extends OAuthAgentException { 21 | 22 | // By default assume a configuration error 23 | public statusCode = 400 24 | public code = 'authorization_error' 25 | 26 | constructor(grant: Grant, status: number, responseText: string) { 27 | super('A request sent to the Authorization Server was rejected') 28 | 29 | // User info requests can be caused by expiry, in which case inform the SPA so that it can avoid an error display 30 | if (grant === Grant.UserInfo && status == 401) { 31 | this.code = 'token_expired' 32 | this.statusCode = 401 33 | } 34 | 35 | // Refresh tokens will expire eventually, in which case inform the SPA so that it can avoid an error display 36 | if (grant === Grant.RefreshToken && responseText.indexOf('invalid_grant') !== -1) { 37 | this.code = 'session_expired' 38 | this.statusCode = 401 39 | } 40 | 41 | this.logInfo = `${Grant[grant]} request failed with status: ${status} and response: ${responseText}` 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/exceptions/AuthorizationResponseException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | // Thrown when the OpenId Connect response returns a URL like this: 20 | // https://www.example.com?state=state=nu2febouwefbjfewbj&error=invalid_scope&error_description= 21 | export default class AuthorizationResponseException extends OAuthAgentException { 22 | public statusCode = 400 23 | public code: string 24 | 25 | constructor(error: string, description: string) { 26 | super(description) 27 | 28 | // Return the error code to the browser, eg invalid_scope 29 | this.code = error 30 | 31 | // Treat the prompt=none response as expiry related 32 | if (this.code === 'login_required') { 33 | this.statusCode = 401 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/exceptions/AuthorizationServerException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class AuthorizationServerException extends OAuthAgentException { 20 | public statusCode = 502 21 | public code = 'authorization_server_error' 22 | public cause?: Error 23 | 24 | constructor(cause?: Error) { 25 | super('A problem occurred with a request to the Authorization Server') 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/CookieDecryptionException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class CookieDecryptionException extends OAuthAgentException { 20 | public statusCode = 401 21 | public code = 'unauthorized_request' 22 | public cause? 23 | 24 | constructor(cause?: Error) { 25 | super("Access denied due to invalid request details") 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidCookieException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class InvalidCookieException extends OAuthAgentException { 20 | public statusCode = 401 21 | public code = 'unauthorized_request' 22 | public cause? 23 | 24 | constructor(cause?: Error) { 25 | super("Access denied due to invalid request details") 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidIDTokenException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class InvalidIDTokenException extends OAuthAgentException { 20 | public statusCode = 400 21 | public code = 'invalid_request' 22 | public cause?: Error 23 | 24 | constructor(cause?: Error) { 25 | super("ID Token missing or invalid") 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidStateException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class InvalidStateException extends OAuthAgentException { 20 | public statusCode = 400 21 | public code = 'invalid_request' 22 | 23 | constructor() { 24 | super("State parameter mismatch when completing a login") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/exceptions/MissingCodeVerifierException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class MissingCodeVerifierException extends OAuthAgentException { 20 | public statusCode = 400 21 | public code = 'invalid_request' 22 | 23 | constructor() { 24 | super("Missing code verifier when completing a login") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/exceptions/OAuthAgentException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export default abstract class OAuthAgentException extends Error { 18 | public statusCode = 500 19 | public code = 'server_error' 20 | public logInfo: string = '' 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/exceptions/UnauthorizedException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class UnauthorizedException extends OAuthAgentException { 20 | public statusCode = 401 21 | public code = 'unauthorized_request' 22 | public cause?: Error 23 | 24 | constructor(cause?: Error) { 25 | super("Access denied due to invalid request details") 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/UnhandledException.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | 19 | export default class UnhandledException extends OAuthAgentException { 20 | public statusCode = 500 21 | public code = 'server_error' 22 | public cause? 23 | 24 | constructor(cause?: Error) { 25 | super("A technical problem occurred in the OAuth Agent") 26 | this.cause = cause 27 | } 28 | } -------------------------------------------------------------------------------- /src/lib/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentException from './OAuthAgentException.js' 18 | import InvalidCookieException from './InvalidCookieException.js' 19 | import CookieDecryptionException from './CookieDecryptionException.js' 20 | import InvalidIDTokenException from './InvalidIDTokenException.js' 21 | import MissingTempLoginDataException from './MissingCodeVerifierException.js' 22 | import InvalidStateException from './InvalidStateException.js' 23 | import UnauthorizedException from './UnauthorizedException.js' 24 | import AuthorizationClientException from './AuthorizationClientException.js' 25 | import AuthorizationResponseException from './AuthorizationResponseException.js' 26 | import AuthorizationServerException from './AuthorizationServerException.js' 27 | import UnhandledException from './UnhandledException.js' 28 | 29 | export { 30 | OAuthAgentException, 31 | InvalidCookieException, 32 | CookieDecryptionException, 33 | InvalidIDTokenException, 34 | MissingTempLoginDataException, 35 | InvalidStateException, 36 | UnauthorizedException, 37 | AuthorizationClientException, 38 | AuthorizationResponseException, 39 | AuthorizationServerException, 40 | UnhandledException, 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/extraParams.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export interface ExtraParam { 18 | key: string; 19 | value: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/getIDTokenClaims.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {decryptCookie} from './cookieEncrypter.js' 18 | import {InvalidCookieException, InvalidIDTokenException} from './exceptions/index.js' 19 | 20 | function getIDTokenClaims(encKey: string, encryptedCookie: string): Object { 21 | 22 | let idToken = null 23 | try { 24 | idToken = decryptCookie(encKey, encryptedCookie) 25 | } catch (err: any) { 26 | const error = new InvalidCookieException(err) 27 | error.logInfo = 'Unable to decrypt the ID cookie to get claims' 28 | throw error 29 | } 30 | 31 | const tokenParts = idToken.split('.') 32 | if (tokenParts.length !== 3) { 33 | throw new InvalidIDTokenException() 34 | } 35 | 36 | try { 37 | const claims = JSON.parse(String(Buffer.from(tokenParts[1], 'base64').toString('binary'))) 38 | return claims 39 | } catch (err: any) { 40 | throw new InvalidIDTokenException(err) 41 | } 42 | } 43 | 44 | export default getIDTokenClaims 45 | -------------------------------------------------------------------------------- /src/lib/getLogoutURL.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js' 18 | 19 | function getLogoutURL(config: OAuthAgentConfiguration): string { 20 | const postLogoutRedirectUriParam = config.postLogoutRedirectURI ? "&post_logout_redirect_uri=" + encodeURIComponent(config.postLogoutRedirectURI) : "" 21 | 22 | return config.logoutEndpoint + "?client_id=" + encodeURIComponent(config.clientID) + postLogoutRedirectUriParam 23 | } 24 | 25 | export default getLogoutURL 26 | -------------------------------------------------------------------------------- /src/lib/getToken.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fetch from 'node-fetch' 18 | import {decryptCookie} from './cookieEncrypter.js' 19 | import {Grant} from './grant.js' 20 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js' 21 | import { 22 | OAuthAgentException, 23 | InvalidStateException, 24 | MissingTempLoginDataException, 25 | AuthorizationClientException, 26 | AuthorizationServerException 27 | } from './exceptions/index.js' 28 | 29 | async function getTokenEndpointResponse(config: OAuthAgentConfiguration, code: string, state: string, tempLoginData: string | undefined | null, ): Promise { 30 | if (!tempLoginData) { 31 | return Promise.reject(new MissingTempLoginDataException()) 32 | } 33 | 34 | const parsedTempLoginData = JSON.parse(decryptCookie(config.encKey, tempLoginData)) 35 | 36 | if (parsedTempLoginData.state !== state) { 37 | return Promise.reject(new InvalidStateException()) 38 | } 39 | 40 | try { 41 | const res = await fetch( 42 | config.tokenEndpoint, 43 | { 44 | method: 'POST', 45 | headers: { 46 | 'Authorization': 'Basic ' + Buffer.from(config.clientID+ ":" + config.clientSecret).toString('base64'), 47 | 'Content-Type': 'application/x-www-form-urlencoded' 48 | }, 49 | body: 'grant_type=authorization_code&redirect_uri=' + config.redirectUri + '&code=' + code + '&code_verifier=' + parsedTempLoginData.codeVerifier 50 | }) 51 | 52 | const text = await res.text() 53 | 54 | if (res.status >= 500) { 55 | const error = new AuthorizationServerException() 56 | error.logInfo = `Server error response in an Authorization Code Grant: ${text}` 57 | throw error 58 | } 59 | 60 | if (res.status >= 400) { 61 | throw new AuthorizationClientException(Grant.AuthorizationCode, res.status, text) 62 | } 63 | 64 | return JSON.parse(text) 65 | 66 | } catch (err: any) { 67 | 68 | if (!(err instanceof OAuthAgentException)) { 69 | const error = new AuthorizationServerException(err) 70 | error.logInfo = 'Connectivity problem during an Authorization Code Grant' 71 | throw error 72 | } else { 73 | throw err 74 | } 75 | } 76 | } 77 | 78 | async function refreshAccessToken(refreshToken: string, config: OAuthAgentConfiguration): Promise 79 | { 80 | try { 81 | 82 | const res = await fetch( 83 | config.tokenEndpoint, 84 | { 85 | method: 'POST', 86 | headers: { 87 | 'Authorization': 'Basic ' + Buffer.from(config.clientID + ":" + config.clientSecret).toString('base64'), 88 | 'Content-Type': 'application/x-www-form-urlencoded' 89 | }, 90 | body: 'grant_type=refresh_token&refresh_token='+refreshToken 91 | }) 92 | 93 | // Read text if it exists 94 | const text = await res.text() 95 | 96 | if (res.status >= 500) { 97 | const error = new AuthorizationServerException() 98 | error.logInfo = `Server error response in a Refresh Token Grant: ${text}` 99 | throw error 100 | } 101 | 102 | if (res.status >= 400) { 103 | throw new AuthorizationClientException(Grant.RefreshToken, res.status, text) 104 | } 105 | 106 | return JSON.parse(text) 107 | 108 | } catch (err: any) { 109 | 110 | if (!(err instanceof OAuthAgentException)) { 111 | 112 | const error = new AuthorizationServerException(err) 113 | error.logInfo = 'Connectivity problem during a Refresh Token Grant' 114 | throw error 115 | 116 | } else { 117 | throw err 118 | } 119 | } 120 | } 121 | 122 | export { getTokenEndpointResponse, refreshAccessToken } 123 | -------------------------------------------------------------------------------- /src/lib/getUserInfo.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fetch from 'node-fetch' 18 | import {decryptCookie} from './cookieEncrypter.js' 19 | import {Grant} from './grant.js' 20 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js' 21 | import { 22 | OAuthAgentException, 23 | InvalidCookieException, 24 | AuthorizationClientException, 25 | AuthorizationServerException 26 | } from './exceptions/index.js' 27 | 28 | async function getUserInfo(config: OAuthAgentConfiguration, encKey: string, encryptedCookie: string): Promise { 29 | 30 | try { 31 | let accessToken = null 32 | try { 33 | accessToken = decryptCookie(encKey, encryptedCookie) 34 | } catch (err: any) { 35 | const error = new InvalidCookieException(err) 36 | error.logInfo = 'Unable to decrypt the access token cookie to get user info' 37 | throw error 38 | } 39 | 40 | const res = await fetch( 41 | config.userInfoEndpoint, 42 | { 43 | method: 'POST', 44 | headers: { 45 | 'Authorization': 'Bearer ' + accessToken, 46 | 'Content-Type': 'application/x-www-form-urlencoded' 47 | }, 48 | }) 49 | 50 | // Read text if it exists 51 | const text = await res.text() 52 | 53 | if (res.status >= 500) { 54 | const error = new AuthorizationServerException() 55 | error.logInfo = `Server error response in a User Info request: ${text}` 56 | throw error 57 | } 58 | 59 | if (res.status >= 400) { 60 | throw new AuthorizationClientException(Grant.UserInfo, res.status, text) 61 | } 62 | 63 | return JSON.parse(text) 64 | 65 | } catch (err: any) { 66 | 67 | if (!(err instanceof OAuthAgentException)) { 68 | const error = new AuthorizationServerException(err) 69 | error.logInfo = 'Connectivity problem during a User Info request' 70 | throw error 71 | } else { 72 | throw err 73 | } 74 | } 75 | } 76 | 77 | export default getUserInfo 78 | -------------------------------------------------------------------------------- /src/lib/grant.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export enum Grant { 18 | AuthorizationCode, 19 | UserInfo, 20 | RefreshToken, 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/idTokenValidator.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {decodeJwt} from 'jose'; 18 | import {InvalidIDTokenException} from './exceptions/index.js'; 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js'; 20 | 21 | /* 22 | * Make some sanity checks to ensure that the issuer and audience are configured correctly 23 | * The ID token is received over a trusted back channel connection so its signature does not need verifying 24 | * https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation 25 | */ 26 | export function validateIDtoken(config: OAuthAgentConfiguration, idToken: string) { 27 | 28 | // For backwards compatibility, only validate the issuer when one is configured 29 | if (process.env.ISSUER) { 30 | 31 | const payload = decodeJwt(idToken) 32 | 33 | if (payload.iss !== config.issuer) { 34 | throw new InvalidIDTokenException(new Error('Unexpected iss claim')) 35 | } 36 | 37 | const audience = getAudienceClaim(payload.aud) 38 | if (audience.indexOf(config.clientID) === -1) { 39 | throw new InvalidIDTokenException(new Error('Unexpected aud claim')) 40 | } 41 | } 42 | } 43 | 44 | function getAudienceClaim(aud: any): string[] { 45 | 46 | if (typeof aud === 'string') { 47 | return [aud] 48 | } 49 | 50 | if (Array.isArray(aud)) { 51 | return aud 52 | } 53 | 54 | return [] 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import type { ClientOptions } from './clientOptions.js' 18 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js' 19 | import { createAuthorizationRequest, handleAuthorizationResponse } from './loginHandler.js' 20 | import { validateIDtoken } from './idTokenValidator.js' 21 | import { ValidateRequestOptions } from './validateRequest.js' 22 | import { getEncryptedCookie, decryptCookie } from './cookieEncrypter.js' 23 | import { getCookiesForTokenResponse, getCookiesForUnset } from './cookieBuilder.js' 24 | import { getTokenEndpointResponse, refreshAccessToken } from './getToken.js' 25 | import getUserInfo from './getUserInfo.js' 26 | import getIDTokenClaims from './getIDTokenClaims.js' 27 | import getRedirectUri from './redirectUri.js' 28 | import getLogoutURL from './getLogoutURL.js' 29 | import { getTempLoginDataCookie, getTempLoginDataCookieForUnset, generateRandomString } from './pkce.js' 30 | import { getAuthCookieName, getIDCookieName, getCSRFCookieName, getATCookieName, getTempLoginDataCookieName } from './cookieName.js' 31 | 32 | export { 33 | ClientOptions, 34 | OAuthAgentConfiguration, 35 | ValidateRequestOptions, 36 | createAuthorizationRequest, 37 | handleAuthorizationResponse, 38 | validateIDtoken, 39 | getEncryptedCookie, 40 | decryptCookie, 41 | getTokenEndpointResponse, 42 | getUserInfo, 43 | getIDTokenClaims, 44 | getRedirectUri, 45 | getLogoutURL, 46 | refreshAccessToken, 47 | getCookiesForUnset, 48 | getTempLoginDataCookieForUnset, 49 | getTempLoginDataCookie, 50 | getCookiesForTokenResponse, 51 | getATCookieName, 52 | getTempLoginDataCookieName, 53 | getCSRFCookieName, 54 | getIDCookieName, 55 | getAuthCookieName, 56 | generateRandomString, 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/loginHandler.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import urlparse from 'url-parse' 18 | import {ClientOptions} from './clientOptions.js'; 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration.js'; 20 | import {generateHash, generateRandomString} from './pkce.js'; 21 | import {AuthorizationRequestData} from './authorizationRequestData.js'; 22 | import {AuthorizationResponseException} from './exceptions/index.js' 23 | 24 | export function createAuthorizationRequest(config: OAuthAgentConfiguration, options?: ClientOptions): AuthorizationRequestData { 25 | 26 | const codeVerifier = generateRandomString() 27 | const state = generateRandomString() 28 | 29 | let authorizationRequestUrl = config.authorizeEndpoint + "?" + 30 | "client_id=" + encodeURIComponent(config.clientID) + 31 | "&redirect_uri=" + encodeURIComponent(config.redirectUri) + 32 | "&response_type=code" + 33 | "&state=" + encodeURIComponent(state) + 34 | "&code_challenge=" + generateHash(codeVerifier) + 35 | "&code_challenge_method=S256" 36 | 37 | if (options && options.extraParams) { 38 | options.extraParams.forEach((p) => { 39 | if (p.key && p.value) { 40 | authorizationRequestUrl += `&${p.key}=${encodeURIComponent(p.value)}` 41 | } 42 | }); 43 | } 44 | 45 | if (config.scope) { 46 | authorizationRequestUrl += "&scope=" + encodeURIComponent(config.scope) 47 | } 48 | 49 | return new AuthorizationRequestData(authorizationRequestUrl, codeVerifier, state) 50 | } 51 | 52 | export async function handleAuthorizationResponse(pageUrl?: string): Promise { 53 | 54 | const data = getUrlParts(pageUrl) 55 | 56 | if (data.state && data.code) { 57 | 58 | return { 59 | code: data.code, 60 | state: data.state, 61 | } 62 | } 63 | 64 | if (data.state && data.error) { 65 | 66 | throw new AuthorizationResponseException( 67 | data.error, 68 | data.error_description || 'Login failed at the Authorization Server') 69 | } 70 | 71 | return { 72 | code: null, 73 | state: null, 74 | } 75 | } 76 | 77 | function getUrlParts(url?: string): any { 78 | 79 | if (url) { 80 | const urlData = urlparse(url, true) 81 | if (urlData.query) { 82 | return urlData.query 83 | } 84 | } 85 | 86 | return {} 87 | } 88 | -------------------------------------------------------------------------------- /src/lib/oauthAgentConfiguration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {SerializeOptions} from 'cookie' 18 | 19 | export default class OAuthAgentConfiguration { 20 | 21 | // Host settings 22 | public port: string 23 | public endpointsPrefix: string 24 | public serverCertPath: string 25 | public serverCertPassword: string 26 | 27 | // Client Configuration 28 | public clientID: string 29 | public clientSecret: string 30 | public redirectUri: string 31 | public postLogoutRedirectURI: string 32 | public scope: string 33 | 34 | // Authorization Server settings 35 | public issuer: string; 36 | public authorizeEndpoint: string 37 | public logoutEndpoint: string 38 | public tokenEndpoint: string 39 | public userInfoEndpoint: string 40 | 41 | // Secure cookie and CORS configuration 42 | public cookieNamePrefix: string 43 | public encKey: string 44 | public trustedWebOrigins: string[] 45 | public corsEnabled: boolean 46 | public cookieOptions: SerializeOptions 47 | 48 | constructor( 49 | port: string, 50 | endpointsPrefix: string, 51 | serverCertPath: string, 52 | serverCertPassword: string, 53 | clientID: string, 54 | clientSecret: string, 55 | redirectUri: string, 56 | postLogoutRedirectURI: string, 57 | scope: string, 58 | issuer: string, 59 | authorizeEndpoint: string, 60 | logoutEndpoint: string, 61 | tokenEndpoint: string, 62 | userInfoEndpoint: string, 63 | cookieNamePrefix: string, 64 | encKey: string, 65 | trustedWebOrigins: string[], 66 | corsEnabled: boolean, 67 | cookieOptions?: SerializeOptions) { 68 | 69 | this.port = port 70 | this.endpointsPrefix = endpointsPrefix 71 | this.serverCertPath = serverCertPath 72 | this.serverCertPassword = serverCertPassword 73 | 74 | this.clientID = clientID 75 | this.clientSecret = clientSecret 76 | this.redirectUri = redirectUri 77 | this.postLogoutRedirectURI = postLogoutRedirectURI 78 | this.scope = scope 79 | 80 | this.cookieNamePrefix = cookieNamePrefix ? cookieNamePrefix : "oauthagent" 81 | this.encKey = encKey 82 | this.trustedWebOrigins = trustedWebOrigins 83 | this.corsEnabled = corsEnabled 84 | this.cookieOptions = cookieOptions ? cookieOptions : { 85 | httpOnly: true, 86 | secure: true, 87 | sameSite: true 88 | } as SerializeOptions 89 | 90 | this.issuer = issuer 91 | this.authorizeEndpoint = authorizeEndpoint 92 | this.logoutEndpoint = logoutEndpoint 93 | this.tokenEndpoint = tokenEndpoint 94 | this.userInfoEndpoint = userInfoEndpoint 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/pkce.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import crypto from 'crypto' 18 | import {SerializeOptions, serialize} from 'cookie' 19 | import {getTempLoginDataCookieName} from './cookieName.js' 20 | import {encryptCookie} from './cookieEncrypter.js' 21 | 22 | const VALID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 23 | const DAY_MILLISECONDS = 1000 * 60 * 60 * 24 24 | 25 | function generateRandomString(length = 64): string { 26 | const array = new Uint8Array(length) 27 | crypto.randomFillSync(array) 28 | const mappedArray = array.map(x => VALID_CHARS.charCodeAt(x % VALID_CHARS.length)) 29 | return String.fromCharCode.apply(null, [...mappedArray]) 30 | } 31 | 32 | function generateHash(data: string): string { 33 | const hash = crypto.createHash('sha256') 34 | hash.update(data) 35 | const hashedData = hash.digest('base64') 36 | 37 | return base64UrlEncode(hashedData) 38 | } 39 | 40 | function base64UrlEncode(hashedData: string): string { 41 | return hashedData 42 | .replace(/=/g, '') 43 | .replace(/\+/g, '-') 44 | .replace(/\//g, '_') 45 | } 46 | 47 | function getTempLoginDataCookie(codeVerifier: string, state: string, options: SerializeOptions, cookieNamePrefix: string, encKey: string): string { 48 | return serialize(getTempLoginDataCookieName(cookieNamePrefix), encryptCookie(encKey, JSON.stringify({ codeVerifier, state })), options) 49 | } 50 | 51 | function getTempLoginDataCookieForUnset(options: SerializeOptions, cookieNamePrefix: string): string { 52 | const cookieOptions = { 53 | ...options, 54 | expires: new Date(Date.now() - DAY_MILLISECONDS) 55 | } 56 | 57 | return serialize(getTempLoginDataCookieName(cookieNamePrefix), "", cookieOptions) 58 | } 59 | 60 | export {generateHash, generateRandomString, getTempLoginDataCookie, getTempLoginDataCookieForUnset} 61 | -------------------------------------------------------------------------------- /src/lib/redirectUri.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | function getRedirectUri(baseUrl: string, path: string, scheme: string): string { 18 | return scheme + '://' + baseUrl + path 19 | } 20 | 21 | export default getRedirectUri 22 | -------------------------------------------------------------------------------- /src/lib/validateRequest.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {UnauthorizedException} from './exceptions/index.js' 18 | import {decryptCookie} from './cookieEncrypter.js' 19 | 20 | export default function validateRequest(data: ValidateRequestData, options: ValidateRequestOptions) { 21 | 22 | if (options.requireTrustedOrigin) { 23 | if (data.allowedOrigins.findIndex((value) => value === data.originHeader) == -1) { 24 | 25 | const error = new UnauthorizedException() 26 | error.logInfo = `The call is from an untrusted web origin: ${data.originHeader}` 27 | throw error 28 | } 29 | } 30 | 31 | if (options.requireCsrfHeader) { 32 | 33 | if (data.csrfCookie) { 34 | const decryptedCookie = decryptCookie(data.encKey, data.csrfCookie) 35 | if (decryptedCookie !== data.csrfHeader) { 36 | 37 | const error = new UnauthorizedException() 38 | error.logInfo = 'The CSRF header did not match the CSRF cookie in a POST request' 39 | throw error 40 | } 41 | } else { 42 | 43 | const error = new UnauthorizedException() 44 | error.logInfo = 'No CSRF cookie was supplied in a POST request' 45 | throw error 46 | } 47 | } 48 | } 49 | 50 | // Data to validate 51 | export class ValidateRequestData { 52 | public csrfHeader?: string 53 | public csrfCookie?: string 54 | public originHeader?: string 55 | public allowedOrigins: string[] 56 | public encKey: string 57 | 58 | public constructor( 59 | csrfHeader: string | undefined, 60 | csrfCookie: string | undefined, 61 | originHeader: string | undefined, 62 | allowedOrigins: string[], 63 | encKey: string) { 64 | 65 | this.csrfHeader = csrfHeader 66 | this.csrfCookie = csrfCookie 67 | this.originHeader = originHeader 68 | this.allowedOrigins = allowedOrigins 69 | this.encKey = encKey 70 | } 71 | } 72 | 73 | // Specific API operations can indicate which validation they need 74 | export class ValidateRequestOptions { 75 | 76 | public requireTrustedOrigin: boolean 77 | public requireCsrfHeader: boolean 78 | 79 | public constructor() { 80 | this.requireTrustedOrigin = true 81 | this.requireCsrfHeader = true 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/middleware/exceptionMiddleware.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {NextFunction, Request, Response} from 'express'; 18 | import {config} from '../config.js' 19 | import {getCookiesForUnset } from'../lib/index.js'; 20 | import {OAuthAgentException, UnhandledException} from '../lib/exceptions/index.js' 21 | import {RequestLog} from './requestLog.js'; 22 | 23 | export default function exceptionMiddleware( 24 | caught: any, 25 | request: Request, 26 | response: Response, 27 | next: NextFunction): void { 28 | 29 | const exception = caught instanceof OAuthAgentException ? caught : new UnhandledException(caught) 30 | 31 | if (!response.locals.log) { 32 | 33 | // For malformed JSON errors, middleware does not get created so write the whole log here 34 | response.locals.log = new RequestLog() 35 | response.locals.log.start(request) 36 | response.locals.log.addError(exception) 37 | response.locals.log.end(response) 38 | 39 | } else { 40 | 41 | // Otherwise just include error details in logs 42 | response.locals.log.addError(exception) 43 | } 44 | 45 | const statusCode = exception.statusCode 46 | const data = { code: exception.code, message: exception.message} 47 | 48 | // Send the error response to the client and remove cookies when the session expires 49 | response.status(statusCode) 50 | if (data.code === 'session_expired') { 51 | response.setHeader('Set-Cookie', getCookiesForUnset(config.cookieOptions, config.cookieNamePrefix)) 52 | } 53 | response.send(data) 54 | } 55 | 56 | /* 57 | * Unhandled promise rejections may not be caught properly 58 | * https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016 59 | */ 60 | export function asyncCatch(fn: any): any { 61 | 62 | return (request: Request, response: Response, next: NextFunction) => { 63 | 64 | Promise 65 | .resolve(fn(request, response, next)) 66 | .catch((e) => { 67 | exceptionMiddleware(e, request, response, next) 68 | }) 69 | }; 70 | } -------------------------------------------------------------------------------- /src/middleware/loggingMiddleware.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {NextFunction, Request, Response} from 'express' 18 | import {RequestLog} from './requestLog.js' 19 | 20 | export default function loggingMiddleware( 21 | request: Request, 22 | response: Response, 23 | next: NextFunction) { 24 | 25 | response.locals.log = new RequestLog() 26 | response.locals.log.start(request) 27 | 28 | response.on('finish', () => { 29 | response.locals.log.end(response) 30 | }) 31 | 32 | next() 33 | } 34 | -------------------------------------------------------------------------------- /src/middleware/requestLog.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import {Request, Response} from 'express' 18 | import {OAuthAgentException} from '../lib/exceptions/index.js'; 19 | 20 | export class RequestLog { 21 | 22 | private time?: string 23 | private method?: string 24 | private path?: string 25 | private status?: number 26 | private error?: OAuthAgentException 27 | 28 | public start(request: Request) { 29 | 30 | this.time = new Date().toUTCString() 31 | this.method = request.method 32 | this.path = request.originalUrl 33 | } 34 | 35 | public addError(error: OAuthAgentException) { 36 | this.error = error 37 | } 38 | 39 | public end(response: Response) { 40 | 41 | this.status = response.statusCode 42 | this._output() 43 | } 44 | 45 | private _output() { 46 | 47 | // Only output log details when there is an error 48 | if (this.status && this.status >= 400) { 49 | 50 | let stack = '' 51 | let logInfo = '' 52 | if (this.error) { 53 | 54 | logInfo = this.error.logInfo 55 | if (this.error.stack) { 56 | stack = this.error.stack 57 | } 58 | 59 | const cause = (this.error as any).cause 60 | if (cause) { 61 | 62 | if (cause.message) { 63 | logInfo += `, ${cause.message}` 64 | } 65 | if (cause.logInfo) { 66 | logInfo += `, ${cause.logInfo}` 67 | } 68 | if (cause.stack) { 69 | stack = cause.stack 70 | } 71 | } 72 | } 73 | 74 | let fields: string[] = [] 75 | this._addField(fields, this.time) 76 | this._addField(fields, this.method) 77 | this._addField(fields, this.path) 78 | this._addField(fields, this.status?.toString()) 79 | this._addField(fields, this.error?.code) 80 | this._addField(fields, this.error?.message) 81 | this._addField(fields, logInfo) 82 | 83 | // Only include a stack trace when there is a 500 error 84 | if (this.status && this.status >= 500 && stack) { 85 | this._addField(fields, stack) 86 | } 87 | 88 | console.log(fields.join(', ')) 89 | } 90 | } 91 | 92 | private _addField(fields: string[], value?: string) { 93 | 94 | if (value) { 95 | fields.push(value) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import express from 'express' 18 | import cors from 'cors' 19 | import cookieParser from 'cookie-parser' 20 | import fs from 'fs' 21 | import https from 'https' 22 | import { 23 | LoginController, 24 | UserInfoController, 25 | ClaimsController, 26 | LogoutController, 27 | RefreshTokenController 28 | } from './controller/index.js' 29 | import {config} from './config.js' 30 | import loggingMiddleware from './middleware/loggingMiddleware.js' 31 | import exceptionMiddleware from './middleware/exceptionMiddleware.js' 32 | 33 | const app = express() 34 | const corsConfiguration = { 35 | origin: config.trustedWebOrigins, 36 | credentials: true, 37 | methods: ['POST'] 38 | } 39 | 40 | if (config.corsEnabled) { 41 | app.use(cors(corsConfiguration)) 42 | } 43 | 44 | app.use(cookieParser()) 45 | app.use('*', express.json()) 46 | app.use('*', loggingMiddleware) 47 | app.use('*', exceptionMiddleware) 48 | app.set('etag', false) 49 | 50 | const controllers = { 51 | '/login': new LoginController(), 52 | '/userInfo': new UserInfoController(), 53 | '/claims': new ClaimsController(), 54 | '/logout': new LogoutController(), 55 | '/refresh': new RefreshTokenController() 56 | } 57 | 58 | for (const [path, controller] of Object.entries(controllers)) { 59 | app.use(config.endpointsPrefix + path, controller.router) 60 | } 61 | 62 | if (config.serverCertPath) { 63 | 64 | const pfx = fs.readFileSync(config.serverCertPath); 65 | const sslOptions = { 66 | pfx, 67 | passphrase: config.serverCertPassword, 68 | }; 69 | 70 | const httpsServer = https.createServer(sslOptions, app); 71 | httpsServer.listen(config.port, () => { 72 | console.log(`OAuth Agent is listening on HTTPS port ${config.port}`); 73 | }); 74 | 75 | } else { 76 | 77 | app.listen(config.port, function() { 78 | console.log(`OAuth Agent is listening on HTTP port ${config.port}`) 79 | }) 80 | } -------------------------------------------------------------------------------- /src/validateExpressRequest.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Curity AB 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import validateRequest, {ValidateRequestData, ValidateRequestOptions} from './lib/validateRequest.js' 18 | import {config} from './config.js' 19 | import {getCSRFCookieName} from './lib/index.js' 20 | import express from 'express' 21 | 22 | export default function validateExpressRequest(req: express.Request, options: ValidateRequestOptions) { 23 | 24 | const data = new ValidateRequestData( 25 | req.header('x-' + config.cookieNamePrefix + '-csrf'), 26 | req.cookies && req.cookies[getCSRFCookieName(config.cookieNamePrefix)], 27 | req.header('Origin'), 28 | config.trustedWebOrigins, 29 | config.encKey, 30 | ) 31 | 32 | validateRequest(data, options) 33 | } 34 | -------------------------------------------------------------------------------- /test/end-to-end/idsvr/config-backup.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://login.example.local:8443 5 | 6 | 7 | default-admin-ssl-key 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 12d8a66e397fde5b748a971451ab4d2a6f8204589efa9c4b44d07faa4ed705f7 17 | 18 | 19 | 20 | default 21 | http 22 | authentication-service-anonymous 23 | authentication-service-authentication 24 | authentication-service-registration 25 | token-service-anonymous 26 | token-service-assisted-token 27 | token-service-authorize 28 | token-service-introspect 29 | token-service-revoke 30 | token-service-session 31 | token-service-token 32 | token-service-userinfo 33 | 34 | 35 | 36 | 37 | 38 | 39 | authentication-service 40 | auth:authentication-service 41 | 42 | 43 | 44 | 45 | Username-Password 46 | 47 | default-account-manager 48 | default-credential-manager 49 | 50 | 51 | 52 | 53 | 54 | default-simple-protocol 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | authentication-service-anonymous 63 | /authn/anonymous 64 | auth-anonymous 65 | 66 | 67 | authentication-service-authentication 68 | /authn/authentication 69 | auth-authentication 70 | 71 | 72 | authentication-service-registration 73 | /authn/registration 74 | auth-registration 75 | 76 | 77 | 78 | 79 | 80 | default-signing-key 81 | 82 | default-datasource 83 | 84 | 85 | 86 | 87 | token-service 88 | as:oauth-service 89 | 90 | 91 | 92 | authentication-service 93 | 94 | 95 | 96 | 97 | 98 | 99 | default-credential-manager 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | address 110 | OpenId Connect address scope 111 | address 112 | 113 | 114 | email 115 | OpenId Connect email scope 116 | email 117 | email_verified 118 | 119 | 120 | openid 121 | Standard OpenID Connect scope 122 | 123 | 124 | phone 125 | OpenId Connect phone scope 126 | phone_number 127 | phone_number_verified 128 | 129 | 130 | profile 131 | OpenId Connect profile scope 132 | birthdate 133 | family_name 134 | gender 135 | given_name 136 | locale 137 | middle_name 138 | name 139 | nickname 140 | picture 141 | preferred_username 142 | profile 143 | updated_at 144 | website 145 | zoneinfo 146 | 147 | 148 | 149 | 150 | address 151 | OpenID Connect address claim 152 | account-manager-claims-provider 153 | 154 | 155 | birthdate 156 | OpenID Connect birthdate claim 157 | account-manager-claims-provider 158 | 159 | 160 | email 161 | OpenID Connect email claim 162 | account-manager-claims-provider 163 | 164 | 165 | email_verified 166 | OpenID Connect email_verified claim 167 | account-manager-claims-provider 168 | 169 | 170 | family_name 171 | OpenID Connect family_name claim 172 | account-manager-claims-provider 173 | 174 | 175 | gender 176 | OpenID Connect gender claim 177 | account-manager-claims-provider 178 | 179 | 180 | given_name 181 | OpenID Connect given_name claim 182 | account-manager-claims-provider 183 | 184 | 185 | locale 186 | OpenID Connect locale claim 187 | account-manager-claims-provider 188 | 189 | 190 | middle_name 191 | OpenID Connect middle_name claim 192 | account-manager-claims-provider 193 | 194 | 195 | name 196 | OpenID Connect name claim 197 | account-manager-claims-provider 198 | 199 | 200 | nickname 201 | OpenID Connect nickname claim 202 | account-manager-claims-provider 203 | 204 | 205 | phone_number 206 | OpenID Connect phone_number claim 207 | account-manager-claims-provider 208 | 209 | 210 | phone_number_verified 211 | OpenID Connect phone_number_verified claim 212 | account-manager-claims-provider 213 | 214 | 215 | picture 216 | OpenID Connect picture claim 217 | account-manager-claims-provider 218 | 219 | 220 | preferred_username 221 | OpenID Connect preferred_username claim 222 | account-manager-claims-provider 223 | 224 | 225 | profile 226 | OpenID Connect profile claim 227 | account-manager-claims-provider 228 | 229 | 230 | updated_at 231 | OpenID Connect updated_at claim 232 | account-manager-claims-provider 233 | 234 | 235 | website 236 | OpenID Connect website claim 237 | account-manager-claims-provider 238 | 239 | 240 | zoneinfo 241 | OpenID Connect zoneinfo claim 242 | account-manager-claims-provider 243 | 244 | 245 | account-manager-claims-provider 246 | 247 | 248 | default-account-manager 249 | 250 | 251 | 252 | 253 | 254 | default 255 | 256 | address 257 | birthdate 258 | email 259 | email_verified 260 | family_name 261 | gender 262 | given_name 263 | locale 264 | middle_name 265 | name 266 | nickname 267 | phone_number 268 | phone_number_verified 269 | picture 270 | preferred_username 271 | profile 272 | updated_at 273 | website 274 | zoneinfo 275 | 276 | 277 | default 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | spa-client 288 | spa-client 289 | SPA using an OAuth Agent 290 | $5$TCfYst/ysngW7RbV$rMLasvlJpnQ0wmFoeTrdIHPjVdnsqOVelkqawZ.fZ17 291 | http://www.example.local/ 292 | spa-client 293 | api.example.local 294 | openid 295 | profile 296 | 297 | http://www.example.local/ 298 | 299 | 300 | 301 | 302 | 303 | 304 | spa-client 305 | 306 | true 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | token-service-anonymous 315 | /oauth/v2/oauth-anonymous 316 | disallow 317 | oauth-anonymous 318 | 319 | 320 | token-service-assisted-token 321 | /oauth/v2/oauth-assisted-token 322 | oauth-assisted-token 323 | 324 | 325 | token-service-authorize 326 | /oauth/v2/oauth-authorize 327 | oauth-authorize 328 | 329 | 330 | token-service-introspect 331 | /oauth/v2/oauth-introspect 332 | oauth-introspect 333 | 334 | 335 | token-service-revoke 336 | /oauth/v2/oauth-revoke 337 | oauth-revoke 338 | 339 | 340 | token-service-session 341 | /oauth/v2/oauth-session 342 | oauth-session 343 | 344 | 345 | token-service-token 346 | /oauth/v2/oauth-token 347 | oauth-token 348 | 349 | 350 | token-service-userinfo 351 | /oauth/v2/oauth-userinfo 352 | oauth-userinfo 353 | 354 | 355 | 356 | 357 | 358 | default-signing-key 359 | 360 | default-datasource 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | default-datasource 369 | 370 | jdbc:postgresql://dbserver/idsvr 371 | org.postgresql.Driver 372 | Password1 373 | true 374 | postgres 375 | 376 | 377 | 378 | 379 | 380 | 381 | default-admin-ssl-key 382 | data:application/p12;aes,v:S.MEZVOVcwWkhDREt0bHpiQw==.4yXBSNbN0NDnuzR_uRmiVw==.SGcMGndVvl7ztgHaDRHyjrfPwsSTVzm9H66G1Yp_X1iDyxzNbyyt9Xx0HftJLCis5e9MlADlZdwxKjST3ialG6CCD18Ty8Jf9NJAhRTlQOFJY2VXwA1W40-1a3bSWFexMI9V1g5NV3rfkc9zYQj6WJhj8uyM4U7ORyNwoC_Q7HIpKPEORWgGeiim_R004PEsuJMOxi8JdVbDI9Y8rzKzMXjJCd8QPpnhKQw1PQULWhou5eSjluTIknFeXtoN9xrzDMLAkTO1he46gKgY3Q7AylosGxHAITjma_aU00iz0TBBcXkmsqlV8jnYV6rVroGzz19XnZNNeHCDhSekdgKb9R6H-k4NoKclQHfyVSOez9YnJMTcV5jJDdwOhu6LCXelbsS9sRDSpAYq7DwGnL8a-Lndp_g_csmF53u-voSk7L924Toy32E565CnXdwQ7oqGLanVeVDZOr-B-xKHIBPwtVWM_WhpO2CbxtsF39MGUQfD-x0_cdeaCg6OWJPbUtKBm6QZE5v2G0MsXBnc988Me3889DZve7NTJzGxBHfLIG0k2yG1TkhntZmA_lY9Elf7gaIIxVjXKoXYccFqOVHbC7-XXaI-p6EsrmYyLSJqMrXIFQuNxvTMfqNrCqU8RkEr_TJBPsCSWUdsuXKIpGZzqczt1sGoXvwhhiB9gOjshliL7YVU1DiKdOi-mIWnNEJOo3_ha45JxrM6uXWdgmC_sJ0wrsz2uHSdsCdVKaLBu04HzUgRwSNELJxTkE_txabdfROWndbLHFRVl8o1qoZRKhgeMHTCLMXEasxysVcyYMkyoD2EUC16rh2XyyFpw2ukJmCuRwZGaGWid0Bsvj9rvxakqNVVCdWOdKEttXMxCVPrAp-zGU2Tv64Ok6AdT4qadoq9B6wuwMt8wc__LM9mGQnOtcGfNO5ZI22fA3oP7Ba-iA1HoTgL86_L8WYeVWNUz0coAm-rtseJHWw-kBoIv47XSg4f4zlQuhJA8pCT9VBN8cBQuC2FHHmSAgewnan2Ywm9mqXK3wh89-HNzgks12XOq72NNc3zEAnnLWZNWo5o7_NJ31x7UhWQIDPeBmz-ArL_9ABbIO-CQLEd4IT33RWPieZk60pIgpkVUUiQqDz26iDMEtS1-PRHwBakmJhyiNky_HWNlce7OxVwooi--bG8j5-Wy7xRh1dS4kOAVphVZ4Cc8w7s5dsDDmRbsI8BTbGUZ009PZzagi8BFMThMa0fPup-qVUly6rC5xGYtO0zqVzLuRkLCDe_MTc1cPN9Xc3_0oQFr8z3HZx8OWsa9gdd1Ed6qmvcthxjI2APlsqXsSJE2Fmbd4YQX_JWetneN5g73jS29fZfER3ZufQOHBWBSAYeJhF7bscurH1VrCPHStX6k4eEwSJ1-xgJOGYo7oIvFj0Lmklrp2VjxwDFxCaNWH-FIcZAFaUMsCQTZeRoUW2_0TcoZyEJwVyadeYKo1IoI5DRzhbZOi0-uCw4gF-ah80E5zALUfUKyZcM0BVo3fchXmXVpzyg6H4HoUZrKXW8GMJeNxiVwoqz_MlBPAeEzcUA_L0WBZF1Qp9X3RDIIgLpnDNXg3xtBdqBTmK7vbkT0Xqml3UcYmF0Siw-L37UnRD98A0LnHUcx1bWORAbH5zR7N27kPCOnxLorSEbsdynnqqSN2RwHvMsPDZgvSMCfId6pWgHCWTRZXsa4nDRzWULyfojq1huRWXKrpdHjMWpVI5fSxbg8XEimHVtIcG9f--wEkw1zo7rtl-KetaJQyHlatXhwSLW6b1YbUmqfQ3V-tOGJ8q3ZrBdW-S-R23ag9M4EJBJKeU5Iaa9svnYoIG1Cv0kpH3AJOXRytuMvdfc7tU28ps65A5SJrbbhtMS7SvuVSN9_OhpaooxK9phwcIZGd7-sGxLR309Jpb7WJAnVQZu_FRtAtSHKcc3bFqAdGFDXaWsGlcD4sGBNOVIdUWvMcLPQZmwpgnjz6dkGOCNI11pZYOZKDo4-qnoC0Goiws3p5u7sVhZcPzq6Ony6XTpe21dvZ3n7WFbQxJ7_Le_8xGTNdqtzfEQKhbHay0u-646qfwKr2UqCz4zMvae4HuymIa8Pp8i9r3pFtF9LX6micxHc8CCNdPwE_F-X_b5Ew9ejvzL1eodMEnkhfCm5uqjsGyJZohgaoiE3nEK_vpI_pYNJELQuyZy-3Qu7WgUGp_i0stf11lyuPSzGb8YDBSi_hGduDHdswoTv7PPATarZn11JsUbpaIwiUsZX3De_hB8RIro1VWxGmLdQN5h33trx6m_Q4M_1lL-ChiECq6VBrAG6uo8Ki-iUZRciK8cv3kmTO7saRrtF3bMhuXGOBAI-NTNfd5gK94oFRoEiO1jhuStXTtlrRszyZImk-FPVLB-7FldbR5sxO3IXbFpd-BFrHS_hpXFag__eIgpRSefdpuSJF6mpsfYnXsGSWeIfGkSp0YCVaeedwa3gyUT1AKrQ_Cvjy-OM5sNIOIY39E0wMHgx5_G4YE9WbipVtEjYrCTKCi4hk_Isn-YL6DJNwhcVXnEPJ0t-mGSffD6fInyzkNqrK-FAGKNls7Ij29PoZNpxXlUlMioVtjNJd9iZRYZarW_eIt34R4__pxbRjHgccdW_iXgaAGPkEzldo2f67cN11teaDcwa-3t0HCKwHkg7cPrzyl9HS1Oj9Qh8xRPU4-KqbCiD5X7opFmLgnuT7j9yGTa029qRQAhfNoDIbBJ3On-rBYsfFdcB2JtZtSLDuz5E_wTERLPQOOaDJcqBq83b3F4ryp1ZOvBVQ3Tt9vYNDu8HlEe_y-QXHNxgbQVr94ZCvkufU4zFI7ZZqtQWEQwQm-4eVOyXERvSBK6mwcxLvXzjJmUHJKm11GOw9M94-QnJb-1Xu3R5w41c6MhfZYOuAUL2llx70q1I3QzDSCcsQLwGwqyAGwtI1tScU1F7muTHqrjzLXQzBIq9GWwqOewFzol7nl_bU72uB9fdKLE5BMGJBVsJDqbcCbgx86Fph7RNhJhMKzVUa396nFZBMMdur7fZs3xFyEHu9UJfVDm_v8lcgsgHIC1f1OLGULdfR7BgLrLOZsS3GBd0r8eI7M-tsaVYvDGpYw-sgunes-Lr_KA2icp6wuThbC_LlDNplTugSk_QIavb9gQfkWO5rioS23DfblmijxUWAigyAHiV4RL-Vq5h30Kk5bDrRuzUUmb6SNNTd_ViVIxu-LPS48_nIqwqYHUxcZDND7j1Pi-Hn_TzvKVTDBgXZ4_PNY29LuRL5gFTeTfWkbnp5CIGsj6Gk1wGvn14HGzZgqbpVYF_wQR3I54kzvnvzY_oV9bcVLi6_4qucCgBIcZBXiAHRid2T96M0cHPo_DLU_tHyxPYrzmiSDjsFUBn0cwHi-sox_NSJTN1o1RkiQ45ZsGHtyLQWMXUG3zFcET57E=.WiwcPSv-Jw9A74NxEZLzwok5WZG5Y1hJPhDoZGr4s5o= 383 | 384 | 385 | 386 | 387 | default-signing-key 388 | data:application/p12;aes,v:S.MHU0eVpHZ0lrWWMxcXRSYw==._2sDaS5W3ER6MNclsX7fpQ==.OHmOKIv1qUm_5cK2zhEy8zaSFVE6ByZopnVpmeuzxOZRFymwJx-zqtehmI5JVDLa1BM81AENhmW9H7km1gQIT-sMfs4YVOPH530o1CXjTQsKtwYwsb1lYTfD2rX7kEPZgJgPOK_ykkQR0qmbyg9EVvtkP9rXXHRKUfvhQU6eyNJu2TP3Oa0BnfVku2naFwPsmmNovnVk8AJBIIyA0EP8Thlf14t4QOcEH4nDcOVAR5kma_ZOPP7OcxcxfnPHmruBxBwfkEHPx09V57vavPPzn08HKuVZ1sZ5r46eKN99WoUudJ2DvUyQuyZD0Gxk2VG4YZ4HYiVelwBZ_GDhxCmIdkA0cuDkE8CKBWHjkkJVV3L_ZFgmH1qGCE08CZ6HL4r6OXGAaAg7uSixfz3oWSL1UEFScl6YqAIVU4rHGB9fjYqIQvrX3DJmKn6bUXLJwg98asDk6RRCOiZWEGT4o4nyAFz0hzq088ugDUdBxdF6OxxLIJeKP73GXILAvHXTSV1TtaMAim2tfc2FXGbmkcNV6Kav8-30CkuRbabaOND91cK6YaezN7m154OORP_pI7Jnaghk31QrnQXkHuO69XvJBdUh1HxkeKTf7oFLdUbdLv9Mz75dkLI0gXcsT0IHRxHjMYnT6AHH3PyhNZtj-17n0P-Uxv03FRPJH8jeOv8HFJQGtbmMLdw3bkO6EQA9lA34glXlp5qrGnYrnU05xXq5vNXavCzJggkWL7LTqjfy1Vu_xSKVGA0qTe1JBYmZ-VxQakAepx9sV5IAEe83Pa4AK-Oz6KqARFZvvs1dg-4YVhHquvrDcIjsj-lT5ENkMdJahtRiyxkPSCOfwN0I2m0fx8Y5XZiUp9PzzoJFxFQY51Sj4Dq4GwVlqrN6-tjf5ducx4q9pK8qYNYSJjbxaLONlb64CwsrG7QVQB4wUYVyPB3YiH8QKv7fo8lfxpDAk4FSsjDUKxHi9Hl1RzDauUhlBVUFIU4oYdM69bjf---i8IV8fKcy15jtqMzjpn6q1vvXI8zx5rWBhRXp5X6uPYUHP7x_W-STABQubHl84zVhnW3Eo-WTKj1iPnxaoZHADg83lC2hMxUA7FcM7sHp1j-4a_MUrRIT4S_VGsOTZn68YMwqSP6uecwQaOL5_HkLBYzp0ifIAhO5aYgaVUnplpxI1MkL8bt5R2z2dhxCS7v4XZAa15Lw1ylmBMZeTCxidBsPXqsZR691vr04gRfk8nE6LmtNWjBLqgUhlgohjruyfG95MfAE2rAwcrmUIVQV8AWPdTIO24OEPeg6-BKLKFIkdnWCxQAQvj4ownCtzVBdAZT61Pc2lOXZ_VmuyvkHBI_Y1oPynQnpcp_60IVaXMU2_BTiDaFmBClEwLm48ElSad-XHNDaDDtrNJEWV770Ep9Ve4YADo2z84pwCRljkYQBfGyldb2Wvh7iJjoc4nvyoM5pQuBDp_jpt06p8QIpK5rQf_RNnB6HmpRevpOxepRtVdaylKvnq8_14RrtIU4JPWxyZa2RVz86kBfXpk_w3PfC0JtoPy3OD5hRDe_Dbn-76XMNJkbixlU0iW4clWb0v6KWXS91lBFnhplXzBNptyvB6hv6ltKx8jPqQsgewtg3jbmyClWxb_3Vf92WWDHPD3QZ6PoaMCjm9HK3v8GzHdfPKmbL43cnl-3hPIoEi84epBqcPdkbbi966mjBy3eNav94xlp3i8nt7V85WB7ORqDrjtiJduMTKClAhmTDKNNsW8E1RQZ7aoc9JDIwPyJEUByOoVS5lDAZYbpqHRX0OUxr5MKfc6XBQbkmJ6FOZhFFKcYz-_s6hNOmy9hkPp9SeMa7m9AmMcOWgP1hRrs0cTCBa8v39rL9nInKK4fgOyjhP6PI9HoZHwSu1VCSJ4oO9eruPXNL_ObwXKRnTij1nB2OQ66SvWxILVA-7jyuG_yDMQJVd3gCU4DeGkFrSRzmsXqYBI3Zzs5esgiKhdV8JevnLPa-odylPV_0J0PPRsBdC0hdC3zfjKxxxkDmkZQDNNlyGx3TyWWRjFPDyLSl3BHnwg7KFsXNFpCZ02dxT96rGNWYErpffMlvaqe2hzlzuLLRdmRyvVl9m9kHSoTS1yMPUsU0LCN_pG-I4BWkmcUK4V93D3veXJh-lI1Metg_6dPpEadQt6AdzM6JJAz4FQSb7gfb4nQRyB95cuWb4DSRUgaUHQ2itu9r6gbpWMOVTybctHWzB0T2ADWHqnLoitPcNGRCm3CAmPvcR1Ut3o4VIzMilSXI8SXJMz5vyAmBul-I6CpBFodGxIXCjcQ5FHQY2vXOXaUEdPayqO7hOepj_-o1wgHorSO6nTbxX-KC1B8VXTivONcbRGVjuS4_THS2SmsShn9aLTjVAfnMHM0SzZiBKchEg4dtqHEVFMVptps7qdCbMlH-X2zaUnmLFFWIfsCUEzXyQUxSVx6OBU8ESO0JVFIyO2oa7Bd1w6d4xFRsJh3DuXUKktwJwjQlBar-mH8Q-SJuP4h5fmAMlwW7Fg3Eo6tjK2888DYNMBZIcxFnlV4yWBSSgnYAe9KVuZJxOMLT1y6tqTYQOlbpdm-4h4xgaRZCm-p1bieVsWADd3HU4iYssAVNRPoSFryJgV8Q3FX1ebxy7o4rPv8o9BX-rLpqgzG1FlViuhSzg4Y3ly1-M7ywjhmXDKZH8MvOVbYQjIUd73UOXMCgBktW-3J9-OT7GGT7ITmU8J724HQYsMP7NWKtVixs49zD9Zhjxv8uloqX4MIswh_bS_crJRs_fhjfxz2yDcwFSWmn5j-2g0C68ihIeIKWXRDtkpwWCSPDAhaBTdbK2LW__chLdb2B5woIdrrRce1DHI32Z7221igcJag6ziXcxuveijJHyltVPolXhR4PqiiU5eakLypNV0Aj_RxFsuNSlYcE7HdFjddzgfqDx7cISOq6aAhV1hgEOspa0w-A976C5xWoEwPwpVtmvD2orruZ3JkXMvZBStSVQWCMfjXhlwb8WvLfgZcBXiqRXKspxBK6qa9UYro1KSfoIuvv4A3nWnaEIbHNmGIzxCh7_kOY8HO8MJpKPZc3HB_mJXOu2ehQl5_Vt22tdtzwrPKtZMH4ucTthA_4Y2unb_Jo4J_yiklYUG1t4S80VtLCKzuFrbZshf7XDODMsG4sZOLbN1qpGnbFVS_QfEc1O9j80PXxVuPlajN94hPzAkSVuO8w8pJ0YrZegbZ8Ob_tWSjvGDHgNdlFh8VhLC6HkzzU8gWwNPlbR_JUYAWd0Jfhd_BCHBSlTAMCBa9ZlBcENLGIt8253FtsWRw0DnuyUkp49K4wQ8kvvHctjNrv8YgFPwm_tHOj6PIwET_-Cc2cBISaf7FLn3EDs5GTtAU5-E29ks7j_QDUnSKPA3xLhMGzdvRYUvRCvutboMXpnu3oHeRThEzmgtDy3PTU8fg=.9rizh1a7N8U8PZ5OR0K1fclhN9EshaModixiaH9l7Vk= 389 | 390 | 391 | 392 | 393 | default-signature-verification-key 394 | -----BEGIN CERTIFICATE----- 395 | MIIDYzCCAkugAwIBAgIECNHbkjANBgkqhkiG9w0BAQsFADA1MQswCQYDVQQGEwJz 396 | ZTEPMA0GA1UEChMGY3VyaXR5MRUwEwYDVQQDEwxjdXJpdHlzZXJ2ZXIwHhcNMjEw 397 | NzMwMTA1MDAwWhcNMjYwNzI5MTA1MDAwWjA1MQswCQYDVQQGEwJzZTEPMA0GA1UE 398 | ChMGY3VyaXR5MRUwEwYDVQQDEwxjdXJpdHlzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB 399 | AQUAA4IBDwAwggEKAoIBAQChIW/47wg5n6E+zO2FEZFiL1i9gWqxbzmseCGR1uW8 400 | 20uk1rPfPDvsPT9TEzAoSIW12t2KG1NS9mOw+e+VTi7CTWT6MQcCFgfzv0UBtqW3 401 | Yuxbg3cIVaYJccotCj7iyzQp4Jieu3TbDJoexBnUv2tXfwadOlska5R27JMfs7g/ 402 | ucZX2vbb8pzDmWjVWRV+nt7S0w5MU+Qer++hi8l25bMmsgbZLhwcPGv2Ov8+QD1S 403 | OpI+/JxpQDTZoRTZWBXPk5199JVgHNgj1dlI3WQTvYG/kHdLoYScjqKeSjSvf1bh 404 | 38U45FSCVlf1H3ZIk12jjTylJ/mYtCW+2Q+0VLqdTAJjAgMBAAGjezB5MAwGA1Ud 405 | EwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwCwYDVR0PBAQDAgXgMCgGA1Ud 406 | EQQhMB+CDGN1cml0eXNlcnZlcoIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBR7 407 | qM4iBha5lMROmH4QMvLq9JpE8DANBgkqhkiG9w0BAQsFAAOCAQEACuv/0eFRjmz7 408 | q/Rsg+2DVLumy3Y1qeeCZAqEjBE1LTjdtgOLBfwJN3Vdk80y6MI1ehm+bGD8Y9+o 409 | L9ZiCxQnQWyO8XGwDqIC7bRvA7fgMcfFzuHZFGruIPHNHlXowdQ0MfSkVOmvT24j 410 | 8ftqqJqNyrGSuKjiluRNX7sfl1wj6M9h4NaqftvlWbZrOnjs+fpBb2LCBmyhKcjc 411 | 3kyisfJ6VkB2A21uNCVeaqSddolEl1PwpDm5ZhfXlBvSF8zHGzjY5e+hC5raFzFb 412 | bxbz/QxWV4fDsuzYL+72kVjl1tkIZ7fH5f//kpDR55qaIRiU38wBbfzkbw4H3lHE 413 | nDJWA4rBzw== 414 | -----END CERTIFICATE----- 415 | 416 | 417 | 418 | 419 | 420 | default-datasource 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | default-account-manager 429 | 430 | no-verification 431 | 432 | default-datasource 433 | 434 | 435 | 436 | 437 | default-credential-manager 438 | 439 | 440 | 441 | default-datasource 442 | 443 | 444 | 445 | 446 | 447 | permit 448 | 449 | 450 | admin 451 | admin 452 | 453 | 454 | 455 | admin 456 | admin 457 | 458 | any-access 459 | permit 460 | 461 | 462 | 463 | any-group 464 | * 465 | 466 | tailf-aaa-authentication 467 | tailf-aaa 468 | /aaa/authentication/users/user[name='$USER'] 469 | read update 470 | permit 471 | 472 | 473 | tailf-aaa-user 474 | tailf-aaa 475 | /user[name='$USER'] 476 | create read update delete 477 | permit 478 | 479 | 480 | tailf-webui-user 481 | tailf-webui 482 | /webui/data-stores/user-profile[username='$USER'] 483 | create read update delete 484 | permit 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | admin 493 | 0 494 | 0 495 | $5$9u6hliDa$bfX4eju/Dm4VpI7RjQMpvCjcvJxJCkzT6B8OY2iXuzB 496 | /opt/idsvr/home/admin/.ssh 497 | /opt/idsvr/home/admin 498 | 499 | 500 | 501 | 502 | 503 | 0 504 | \h> 505 | 506 | 507 | 15 508 | \h# 509 | 510 | 511 | exec 512 | 513 | 0 514 | 515 | action 516 | 517 | 518 | autowizard 519 | 520 | 521 | enable 522 | 523 | 524 | exit 525 | 526 | 527 | help 528 | 529 | 530 | startup 531 | 532 | 533 | 534 | 15 535 | 536 | configure 537 | 538 | 539 | 540 | 541 | 542 | -------------------------------------------------------------------------------- /test/end-to-end/idsvr/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ############################################################## 4 | # Deploy the Curity Identity Server with the required settings 5 | # This enables the OAuth Agent to be tested in isolation 6 | ############################################################## 7 | 8 | # 9 | # Ensure that we are in the folder containing this script 10 | # 11 | cd "$(dirname "${BASH_SOURCE[0]}")" 12 | 13 | # 14 | # This is for Curity developers only 15 | # 16 | cp ./pre-commit ../../../.git/hooks 17 | 18 | # 19 | # Check for a license file 20 | # 21 | if [ ! -f './license.json' ]; then 22 | echo "Please provide a license.json file in the test/end-to-end/idsvr folder" 23 | exit 1 24 | fi 25 | 26 | # 27 | # Run Docker to deploy the Curity Identity Server 28 | # 29 | docker compose --project-name oauthagent up --force-recreate 30 | if [ $? -ne 0 ]; then 31 | echo "Problem encountered starting Docker components" 32 | exit 1 33 | fi 34 | 35 | -------------------------------------------------------------------------------- /test/end-to-end/idsvr/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # 4 | # A SQL database used by the Curity Identity Server 5 | # 6 | curity-data: 7 | image: postgres:15.3 8 | hostname: dbserver 9 | volumes: 10 | - ./data-backup.sql:/docker-entrypoint-initdb.d/data-backup.sql 11 | environment: 12 | POSTGRES_USER: 'postgres' 13 | POSTGRES_PASSWORD: 'Password1' 14 | POSTGRES_DB: 'idsvr' 15 | 16 | # 17 | # A standalone instance of the Curity Identity Server 18 | # 19 | curity-idsvr: 20 | image: curity.azurecr.io/curity/idsvr:latest 21 | hostname: idsvr 22 | ports: 23 | - 6749:6749 24 | - 8443:8443 25 | volumes: 26 | - ./license.json:/opt/idsvr/etc/init/license/license.json 27 | - ./config-backup.xml:/opt/idsvr/etc/init/config.xml 28 | environment: 29 | PASSWORD: 'Password1' 30 | -------------------------------------------------------------------------------- /test/end-to-end/idsvr/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # This prevents accidental checkins of sensitive data by Curity developers 5 | # 6 | git diff --cached --name-only | while read FILE; do 7 | 8 | IS_LICENSE_FILE=$(echo "$FILE" | grep -i 'license.json') 9 | if [ "$IS_LICENSE_FILE" != '' ]; then 10 | echo "*** Attempting to check a Curity license file into a public GitHub repository ***" 11 | exit 1 12 | fi 13 | 14 | IS_XML_FILE=$(echo "$FILE" | grep -i '\.xml$') 15 | if [ "$IS_XML_FILE" != '' ]; then 16 | 17 | LICENSE_KEY=$(cat $FILE | grep 'license-key' | sed -r "s/^(.*)(.*)<\/license-key>$/\2/i") 18 | if [ "$LICENSE_KEY" != '' ]; then 19 | 20 | echo "*** Attempting to check a Curity license into a public GitHub repository ***" 21 | exit 1 22 | fi 23 | fi 24 | done 25 | -------------------------------------------------------------------------------- /test/end-to-end/idsvr/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ######################################################################### 4 | # A simple script to free Docker resources when finished with development 5 | ######################################################################### 6 | 7 | # 8 | # Ensure that we are in the folder containing this script 9 | # 10 | cd "$(dirname "${BASH_SOURCE[0]}")" 11 | 12 | # 13 | # Free the Docker resources 14 | # 15 | docker compose --project-name oauthagent down 16 | -------------------------------------------------------------------------------- /test/end-to-end/login.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ############################################################## 4 | # Basic automation to get tokens from the Authorization Server 5 | ############################################################## 6 | 7 | TOKEN_HANDLER_BASE_URL='http://api.example.local:8080/oauth-agent' 8 | WEB_BASE_URL='http://www.example.local' 9 | AUTHORIZATION_SERVER_BASE_URL='http://login.example.local:8443' 10 | RESPONSE_FILE=data/response.txt 11 | LOGIN_COOKIES_FILE=data/login_cookies.txt 12 | CURITY_COOKIES_FILE=data/curity_cookies.txt 13 | MAIN_COOKIES_FILE=data/main_cookies.txt 14 | TEST_USERNAME=demouser 15 | TEST_PASSWORD=Password1 16 | CLIENT_ID=spa-client 17 | #export http_proxy='http://127.0.0.1:8888' 18 | 19 | # 20 | # Ensure that we are in the folder containing this script 21 | # 22 | cd "$(dirname "${BASH_SOURCE[0]}")" 23 | 24 | # 25 | # Get a header value from the HTTP response file 26 | # 27 | function getHeaderValue(){ 28 | local _HEADER_NAME=$1 29 | local _HEADER_VALUE=$(cat $RESPONSE_FILE | grep -i "^$_HEADER_NAME" | sed -r "s/^$_HEADER_NAME: (.*)$/\1/i") 30 | local _HEADER_VALUE=${_HEADER_VALUE%$'\r'} 31 | echo $_HEADER_VALUE 32 | } 33 | 34 | # 35 | # Pattern matching to dig out a field value from an auto submit HTML form, via the second pattern match 36 | # 37 | function getHtmlFormValue(){ 38 | local _FIELD_NAME=$1 39 | local _FIELD_LINE=$(cat $RESPONSE_FILE | grep -i "name=\"$_FIELD_NAME\"") 40 | local _FIELD_VALUE=$(echo $_FIELD_LINE | sed -r "s/^(.*)name=\"$_FIELD_NAME\" value=\"(.*)\"(.*)$/\2/i") 41 | echo $_FIELD_VALUE 42 | } 43 | 44 | # 45 | # Temp data is stored in this folder 46 | # 47 | mkdir -p data 48 | 49 | # 50 | # First get the authorization request URL 51 | # 52 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/start" \ 53 | -H "origin: $WEB_BASE_URL" \ 54 | -H 'content-type: application/json' \ 55 | -H 'accept: application/json' \ 56 | -c $LOGIN_COOKIES_FILE \ 57 | -o $RESPONSE_FILE -w '%{http_code}') 58 | if [ "$HTTP_STATUS" == '000' ]; then 59 | echo '*** Connectivity problem encountered, please check endpoints and whether an HTTP proxy tool is running' 60 | exit 1 61 | fi 62 | if [ "$HTTP_STATUS" != '200' ]; then 63 | echo "*** Start login failed with status $HTTP_STATUS" 64 | exit 1 65 | fi 66 | JSON=$(tail -n 1 $RESPONSE_FILE) 67 | echo $JSON | jq 68 | AUTHORIZATION_REQUEST_URL=$(jq -r .authorizationRequestUrl <<< "$JSON") 69 | 70 | # 71 | # Follow redirects until the login HTML form is returned and save cookies 72 | # 73 | HTTP_STATUS=$(curl -i -L -s -X GET "$AUTHORIZATION_REQUEST_URL" \ 74 | -c $CURITY_COOKIES_FILE \ 75 | -o $RESPONSE_FILE -w '%{http_code}') 76 | if [ $HTTP_STATUS != '200' ]; then 77 | echo "*** Problem encountered during an OpenID Connect authorization redirect, status: $HTTP_STATUS" 78 | exit 1 79 | fi 80 | 81 | # 82 | # Post up the test credentials, sending then regetting cookies 83 | # 84 | HTTP_STATUS=$(curl -i -s -X POST "$AUTHORIZATION_SERVER_BASE_URL/authn/authentication/Username-Password" \ 85 | -H 'Content-Type: application/x-www-form-urlencoded' \ 86 | -b $CURITY_COOKIES_FILE \ 87 | -c $CURITY_COOKIES_FILE \ 88 | --data-urlencode "userName=$TEST_USERNAME" \ 89 | --data-urlencode "password=$TEST_PASSWORD" \ 90 | -o $RESPONSE_FILE -w '%{http_code}') 91 | if [ $HTTP_STATUS != '200' ]; then 92 | echo "*** Problem encountered submitting test user credentials, status: $HTTP_STATUS" 93 | exit 1 94 | fi 95 | 96 | # 97 | # Do the auto form post, providing Identity Server cookies 98 | # 99 | TOKEN=$(getHtmlFormValue 'token') 100 | STATE=$(getHtmlFormValue 'state') 101 | HTTP_STATUS=$(curl -i -s -X POST "$AUTHORIZATION_SERVER_BASE_URL/oauth/v2/oauth-authorize?client_id=$CLIENT_ID" \ 102 | -H 'Content-Type: application/x-www-form-urlencoded' \ 103 | -b $CURITY_COOKIES_FILE \ 104 | -c $CURITY_COOKIES_FILE \ 105 | --data-urlencode "token=$TOKEN" \ 106 | --data-urlencode "state=$STATE" \ 107 | -o $RESPONSE_FILE -w '%{http_code}') 108 | if [ $HTTP_STATUS != '303' ]; then 109 | echo "*** Problem encountered auto posting form, status: $HTTP_STATUS" 110 | exit 1 111 | fi 112 | 113 | # 114 | # Read the response details 115 | # 116 | APP_URL=$(getHeaderValue 'location') 117 | if [ "$APP_URL" == '' ]; then 118 | echo '*** API driven login did not complete successfully' 119 | exit 1 120 | fi 121 | PAGE_URL_JSON='{"pageUrl":"'$APP_URL'"}' 122 | echo $PAGE_URL_JSON | jq 123 | 124 | # 125 | # End the login by swapping the code for tokens 126 | # 127 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 128 | -H "origin: $WEB_BASE_URL" \ 129 | -H 'content-type: application/json' \ 130 | -H 'accept: application/json' \ 131 | -c $MAIN_COOKIES_FILE \ 132 | -b $LOGIN_COOKIES_FILE \ 133 | -d $PAGE_URL_JSON \ 134 | -o $RESPONSE_FILE -w '%{http_code}') 135 | if [ "$HTTP_STATUS" != '200' ]; then 136 | echo "*** Problem encountered ending the login, status $HTTP_STATUS" 137 | JSON=$(tail -n 1 $RESPONSE_FILE) 138 | echo $JSON | jq 139 | exit 1 140 | fi 141 | JSON=$(tail -n 1 $RESPONSE_FILE) 142 | echo $JSON | jq 143 | IS_LOGGED_IN=$(jq -r .isLoggedIn <<< "$JSON") 144 | HANDLED=$(jq -r .handled <<< "$JSON") 145 | if [ "$IS_LOGGED_IN" != 'true' ] || [ "$HANDLED" != 'true' ]; then 146 | echo '*** End login returned an unexpected payload' 147 | exit 1 148 | fi 149 | exit 0 -------------------------------------------------------------------------------- /test/end-to-end/test-oauth-agent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ################################################################ 4 | # Tests to run against OAuth Agent endpoints outside the browser 5 | ################################################################ 6 | 7 | TOKEN_HANDLER_BASE_URL='http://api.example.local:8080/oauth-agent' 8 | WEB_BASE_URL='http://www.example.local' 9 | RESPONSE_FILE=data/response.txt 10 | MAIN_COOKIES_FILE=data/main_cookies.txt 11 | LOGIN_COOKIES_FILE=data/login_cookies.txt 12 | CURITY_COOKIES_FILE=data/curity_cookies.txt 13 | #export http_proxy='http://127.0.0.1:8888' 14 | 15 | # 16 | # Ensure that we are in the folder containing this script 17 | # 18 | cd "$(dirname "${BASH_SOURCE[0]}")" 19 | 20 | # 21 | # Get a header value from the HTTP response file 22 | # 23 | function getHeaderValue(){ 24 | local _HEADER_NAME=$1 25 | local _HEADER_VALUE=$(cat $RESPONSE_FILE | grep -i "^$_HEADER_NAME" | sed -r "s/^$_HEADER_NAME: (.*)$/\1/i") 26 | local _HEADER_VALUE=${_HEADER_VALUE%$'\r'} 27 | echo $_HEADER_VALUE 28 | } 29 | 30 | # 31 | # Temp data is stored in this folder 32 | # 33 | mkdir -p data 34 | 35 | # 36 | # Test sending an invalid web origin to the OAuth Agent in an OPTIONS request 37 | # The logic around CORS is configured, not coded, so ensure that it works as expected 38 | # 39 | echo '1. Testing OPTIONS request with an invalid web origin ...' 40 | HTTP_STATUS=$(curl -i -s -X OPTIONS "$TOKEN_HANDLER_BASE_URL/login/start" \ 41 | -H "origin: http://malicious-site.com" \ 42 | -o $RESPONSE_FILE -w '%{http_code}') 43 | if [ "$HTTP_STATUS" == '000' ]; then 44 | echo '*** Connectivity problem encountered, please check endpoints and whether an HTTP proxy tool is running' 45 | exit 46 | fi 47 | ORIGIN=$(getHeaderValue 'Access-Control-Allow-Origin') 48 | if [ "$ORIGIN" != '' ]; then 49 | echo '*** CORS access was granted to a malicious origin' 50 | exit 51 | fi 52 | echo '1. OPTIONS with invalid web origin was not granted access' 53 | 54 | # 55 | # Test sending a valid web origin to the OAuth Agent in an OPTIONS request 56 | # 57 | echo '2. Testing OPTIONS request with a valid web origin ...' 58 | HTTP_STATUS=$(curl -i -s -X OPTIONS "$TOKEN_HANDLER_BASE_URL/login/start" \ 59 | -H "origin: $WEB_BASE_URL" \ 60 | -o $RESPONSE_FILE -w '%{http_code}') 61 | if [ "$HTTP_STATUS" != '200' ] && [ "$HTTP_STATUS" != '204' ]; then 62 | echo "*** Problem encountered requesting cross origin access, status: $HTTP_STATUS" 63 | exit 64 | fi 65 | ORIGIN=$(getHeaderValue 'Access-Control-Allow-Origin') 66 | if [ "$ORIGIN" != "$WEB_BASE_URL" ]; then 67 | echo '*** The Access-Control-Allow-Origin response header has an unexpected value' 68 | exit 69 | fi 70 | echo '2. OPTIONS with valid web origin granted access successfully' 71 | 72 | # 73 | # Next we will test an unauthenticated page load but first test CORS 74 | # The logic around trusted origins is coded by us 75 | # 76 | echo '3. Testing end login POST with invalid web origin ...' 77 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 78 | -H "origin: http://malicious-site.com" \ 79 | -H 'content-type: application/json' \ 80 | -H 'accept: application/json' \ 81 | -d '{"pageUrl":"'$WEB_BASE_URL'"}' \ 82 | -o $RESPONSE_FILE -w '%{http_code}') 83 | if [ "$HTTP_STATUS" != '401' ]; then 84 | echo '*** End login did not fail as expected' 85 | exit 86 | fi 87 | JSON=$(tail -n 1 $RESPONSE_FILE) 88 | echo $JSON | jq 89 | CODE=$(jq -r .code <<< "$JSON") 90 | if [ "$CODE" != 'unauthorized_request' ]; then 91 | echo "*** End login returned an unexpected error code" 92 | exit 93 | fi 94 | echo '3. POST to endLogin with an invalid web origin was successfully rejected' 95 | 96 | # 97 | # Test sending an end login request to the API as part of an unauthenticated page load 98 | # 99 | echo '4. Testing end login POST for an unauthenticated page load ...' 100 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 101 | -H "origin: $WEB_BASE_URL" \ 102 | -H 'content-type: application/json' \ 103 | -H 'accept: application/json' \ 104 | -d '{"pageUrl":"'$WEB_BASE_URL'"}' \ 105 | -o $RESPONSE_FILE -w '%{http_code}') 106 | if [ "$HTTP_STATUS" != '200' ]; then \ 107 | echo "*** Unauthenticated page load failed with status $HTTP_STATUS" 108 | exit 109 | fi 110 | JSON=$(tail -n 1 $RESPONSE_FILE) 111 | echo $JSON | jq 112 | IS_LOGGED_IN=$(jq -r .isLoggedIn <<< "$JSON") 113 | HANDLED=$(jq -r .handled <<< "$JSON") 114 | if [ "$IS_LOGGED_IN" != 'false' ] || [ "$HANDLED" != 'false' ]; then 115 | echo "*** End login returned an unexpected payload" 116 | exit 117 | fi 118 | echo '4. POST to endLogin for an unauthenticated page load completed successfully' 119 | 120 | # 121 | # Test sending a start login request to the API with an invalid origin header 122 | # The logic around trusted origins is coded by us 123 | # 124 | echo '5. Testing POST to start login from invalid web origin ...' 125 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/start" \ 126 | -H "origin: http://malicious-site.com" \ 127 | -H 'content-type: application/json' \ 128 | -H 'accept: application/json' \ 129 | -o $RESPONSE_FILE -w '%{http_code}') 130 | if [ "$HTTP_STATUS" != '401' ]; then 131 | echo '*** Start Login with an invalid web origin did not fail as expected' 132 | exit 133 | fi 134 | JSON=$(tail -n 1 $RESPONSE_FILE) 135 | echo $JSON | jq 136 | CODE=$(jq -r .code <<< "$JSON") 137 | if [ "$CODE" != 'unauthorized_request' ]; then 138 | echo "*** Start login returned an unexpected error code" 139 | exit 140 | fi 141 | echo '5. POST to startLogin with invalid web origin was not granted access' 142 | 143 | # 144 | # Test sending a valid start login request to the API 145 | # 146 | echo '6. Testing POST to start login ...' 147 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/start" \ 148 | -H "origin: $WEB_BASE_URL" \ 149 | -H 'content-type: application/json' \ 150 | -H 'accept: application/json' \ 151 | -c $LOGIN_COOKIES_FILE \ 152 | -o $RESPONSE_FILE -w '%{http_code}') 153 | if [ "$HTTP_STATUS" != '200' ]; then 154 | echo "*** Start login failed with status $HTTP_STATUS" 155 | exit 156 | fi 157 | JSON=$(tail -n 1 $RESPONSE_FILE) 158 | echo $JSON | jq 159 | echo "6. POST to start login succeeded and returned the authorization request URL" 160 | 161 | # 162 | # Next perform a login to get the URL returned to the web client 163 | # 164 | echo '7. Performing API driven login ...' 165 | ./login.sh 166 | if [ "$?" != '0' ]; then 167 | echo '*** Problem encountered implementing an API driven login' 168 | exit 169 | fi 170 | 171 | # 172 | # Next verify that the OAuth state is correctly verified against the request value 173 | # 174 | echo '8. Testing posting a malicious code and state into the browser ...' 175 | APP_URL="$WEB_BASE_URL?code=hi0f1340y843thy3480&state=nu2febouwefbjfewbj" 176 | PAGE_URL_JSON='{"pageUrl":"'$APP_URL'"}' 177 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 178 | -H "origin: $WEB_BASE_URL" \ 179 | -H 'content-type: application/json' \ 180 | -H 'accept: application/json' \ 181 | -b $LOGIN_COOKIES_FILE \ 182 | -d $PAGE_URL_JSON \ 183 | -o $RESPONSE_FILE -w '%{http_code}') 184 | if [ "$HTTP_STATUS" != '400' ]; then 185 | echo "*** Posting a malicious code and state into the browser did not fail as expected" 186 | exit 187 | fi 188 | JSON=$(tail -n 1 $RESPONSE_FILE) 189 | echo $JSON | jq 190 | CODE=$(jq -r .code <<< "$JSON") 191 | if [ "$CODE" != 'invalid_request' ]; then 192 | echo "*** End login returned an unexpected error code" 193 | exit 194 | fi 195 | echo '8. Posting a malicious code and state into the browser was handled correctly' 196 | 197 | # 198 | # Test an authenticated page load by sending up the main cookies 199 | # 200 | echo '9. Testing an authenticated page load ...' 201 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 202 | -H "origin: $WEB_BASE_URL" \ 203 | -H 'content-type: application/json' \ 204 | -H 'accept: application/json' \ 205 | -b $MAIN_COOKIES_FILE \ 206 | -d '{"pageUrl":"'$WEB_BASE_URL'"}' \ 207 | -o $RESPONSE_FILE -w '%{http_code}') 208 | if [ "$HTTP_STATUS" != '200' ]; then 209 | echo "*** Authenticated page load failed with status $HTTP_STATUS" 210 | exit 211 | fi 212 | JSON=$(tail -n 1 $RESPONSE_FILE) 213 | echo $JSON | jq 214 | CSRF=$(jq -r .csrf <<< "$JSON") 215 | IS_LOGGED_IN=$(jq -r .isLoggedIn <<< "$JSON") 216 | HANDLED=$(jq -r .handled <<< "$JSON") 217 | if [ "$IS_LOGGED_IN" != 'true' ] || [ "$HANDLED" != 'false' ]; then 218 | echo "*** End login returned an unexpected payload" 219 | exit 220 | fi 221 | echo '9. Authenticated page reload was successful' 222 | 223 | # 224 | # Test getting user info with an invalid origin 225 | # 226 | echo '10. Testing GET User Info from an untrusted origin ...' 227 | HTTP_STATUS=$(curl -i -s -X GET "$TOKEN_HANDLER_BASE_URL/userInfo" \ 228 | -H "origin: http://malicious-site.com" \ 229 | -H 'content-type: application/json' \ 230 | -H 'accept: application/json' \ 231 | -o $RESPONSE_FILE -w '%{http_code}') 232 | if [ "$HTTP_STATUS" != '401' ]; then 233 | echo '*** Invalid user info request did not fail as expected' 234 | exit 235 | fi 236 | JSON=$(tail -n 1 $RESPONSE_FILE) 237 | echo $JSON | jq 238 | CODE=$(jq -r .code <<< "$JSON") 239 | if [ "$CODE" != 'unauthorized_request' ]; then 240 | echo "*** User Info returned an unexpected error code" 241 | exit 242 | fi 243 | echo '10. GET User Info request for an untrusted origin was handled correctly' 244 | 245 | # 246 | # Test getting user info without a cookie 247 | # 248 | echo '11. Testing GET User Info without secure cookies ...' 249 | HTTP_STATUS=$(curl -i -s -X GET "$TOKEN_HANDLER_BASE_URL/userInfo" \ 250 | -H "origin: $WEB_BASE_URL" \ 251 | -H 'content-type: application/json' \ 252 | -H 'accept: application/json' \ 253 | -o $RESPONSE_FILE -w '%{http_code}') 254 | if [ "$HTTP_STATUS" != '401' ]; then 255 | echo '*** Invalid user info request did not fail as expected' 256 | exit 257 | fi 258 | JSON=$(tail -n 1 $RESPONSE_FILE) 259 | echo $JSON | jq 260 | CODE=$(jq -r .code <<< "$JSON") 261 | if [ "$CODE" != 'unauthorized_request' ]; then 262 | echo "*** User Info returned an unexpected error code" 263 | exit 264 | fi 265 | echo '11. GET User Info request without secure cookies was handled correctly' 266 | 267 | # 268 | # Test getting user info successfully 269 | # 270 | echo '12. Testing GET User Info with secure cookies ...' 271 | HTTP_STATUS=$(curl -i -s -X GET "$TOKEN_HANDLER_BASE_URL/userInfo" \ 272 | -H "origin: $WEB_BASE_URL" \ 273 | -H 'content-type: application/json' \ 274 | -H 'accept: application/json' \ 275 | -b $MAIN_COOKIES_FILE \ 276 | -o $RESPONSE_FILE -w '%{http_code}') 277 | if [ "$HTTP_STATUS" != '200' ]; then 278 | echo "*** Getting user info failed with status $HTTP_STATUS" 279 | exit 280 | fi 281 | JSON=$(tail -n 1 $RESPONSE_FILE) 282 | echo $JSON | jq 283 | echo "12. GET User Info was successful" 284 | 285 | # 286 | # Test getting ID token claims without a cookie 287 | # 288 | echo '13. Testing GET claims without secure cookies ...' 289 | HTTP_STATUS=$(curl -i -s -X GET "$TOKEN_HANDLER_BASE_URL/claims" \ 290 | -H "origin: $WEB_BASE_URL" \ 291 | -H 'content-type: application/json' \ 292 | -H 'accept: application/json' \ 293 | -o $RESPONSE_FILE -w '%{http_code}') 294 | if [ "$HTTP_STATUS" != '401' ]; then 295 | echo '*** Invalid user info request did not fail as expected' 296 | exit 297 | fi 298 | JSON=$(tail -n 1 $RESPONSE_FILE) 299 | echo $JSON | jq 300 | CODE=$(jq -r .code <<< "$JSON") 301 | if [ "$CODE" != 'unauthorized_request' ]; then 302 | echo "*** User Info returned an unexpected error code" 303 | exit 304 | fi 305 | echo '13. GET claims request without secure cookies was handled correctly' 306 | 307 | # 308 | # Test getting ID token claims successfully 309 | # 310 | echo '14. Testing GET claims with secure cookies ...' 311 | HTTP_STATUS=$(curl -i -s -X GET "$TOKEN_HANDLER_BASE_URL/claims" \ 312 | -H "origin: $WEB_BASE_URL" \ 313 | -H 'content-type: application/json' \ 314 | -H 'accept: application/json' \ 315 | -b $MAIN_COOKIES_FILE \ 316 | -o $RESPONSE_FILE -w '%{http_code}') 317 | if [ "$HTTP_STATUS" != '200' ]; then 318 | echo "*** Getting claims failed with status $HTTP_STATUS" 319 | exit 320 | fi 321 | JSON=$(tail -n 1 $RESPONSE_FILE) 322 | echo $JSON | jq 323 | echo "14. GET claims request was successful" 324 | 325 | # 326 | # Test refreshing a token with an invalid origin 327 | # 328 | echo '15. Testing POST to /refresh from an untrusted origin ...' 329 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 330 | -H "origin: http://malicious-site.com" \ 331 | -H 'content-type: application/json' \ 332 | -H 'accept: application/json' \ 333 | -o $RESPONSE_FILE -w '%{http_code}') 334 | if [ "$HTTP_STATUS" != '401' ]; then 335 | echo '*** Invalid token refresh request did not fail as expected' 336 | exit 337 | fi 338 | JSON=$(tail -n 1 $RESPONSE_FILE) 339 | echo $JSON | jq 340 | CODE=$(jq -r .code <<< "$JSON") 341 | if [ "$CODE" != 'unauthorized_request' ]; then 342 | echo "*** Refresh returned an unexpected error code" 343 | exit 344 | fi 345 | echo '15. POST to /refresh for an untrusted origin was handled correctly' 346 | 347 | # 348 | # Test refreshing a token without a cookie 349 | # 350 | echo '16. Testing POST to /refresh without secure cookies ...' 351 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 352 | -H "origin: $WEB_BASE_URL" \ 353 | -H 'content-type: application/json' \ 354 | -H 'accept: application/json' \ 355 | -o $RESPONSE_FILE -w '%{http_code}') 356 | if [ "$HTTP_STATUS" != '401' ]; then 357 | echo '*** Invalid token refresh request did not fail as expected' 358 | exit 359 | fi 360 | JSON=$(tail -n 1 $RESPONSE_FILE) 361 | echo $JSON | jq 362 | CODE=$(jq -r .code <<< "$JSON") 363 | if [ "$CODE" != 'unauthorized_request' ]; then 364 | echo "*** Refresh returned an unexpected error code" 365 | exit 366 | fi 367 | echo '16. POST to /refresh without secure cookies was handled correctly' 368 | 369 | # 370 | # Test refreshing a token with secure cookies but with a missing anti forgery token 371 | # 372 | echo '17. Testing POST to /refresh without CSRF token ...' 373 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 374 | -H "origin: $WEB_BASE_URL" \ 375 | -H 'content-type: application/json' \ 376 | -H 'accept: application/json' \ 377 | -b $MAIN_COOKIES_FILE \ 378 | -o $RESPONSE_FILE -w '%{http_code}') 379 | if [ "$HTTP_STATUS" != '401' ]; then 380 | echo '*** Invalid token refresh request did not fail as expected' 381 | exit 382 | fi 383 | JSON=$(tail -n 1 $RESPONSE_FILE) 384 | echo $JSON | jq 385 | CODE=$(jq -r .code <<< "$JSON") 386 | if [ "$CODE" != 'unauthorized_request' ]; then 387 | echo "*** Refresh returned an unexpected error code" 388 | exit 389 | fi 390 | echo '17. POST to /refresh without CSRF token was handled correctly' 391 | 392 | # 393 | # Test refreshing a token with secure cookies but with an incorrect anti forgery token 394 | # 395 | echo '18. Testing POST to /refresh with incorrect CSRF token ...' 396 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 397 | -H "origin: $WEB_BASE_URL" \ 398 | -H 'content-type: application/json' \ 399 | -H 'accept: application/json' \ 400 | -H 'x-example-csrf: abc123' \ 401 | -b $MAIN_COOKIES_FILE \ 402 | -o $RESPONSE_FILE -w '%{http_code}') 403 | if [ "$HTTP_STATUS" != '401' ]; then 404 | echo '*** Invalid token refresh request did not fail as expected' 405 | exit 406 | fi 407 | JSON=$(tail -n 1 $RESPONSE_FILE) 408 | echo $JSON | jq 409 | CODE=$(jq -r .code <<< "$JSON") 410 | if [ "$CODE" != 'unauthorized_request' ]; then 411 | echo "*** Refresh returned an unexpected error code" 412 | exit 413 | fi 414 | echo '18. POST to /refresh with incorrect CSRF token was handled correctly' 415 | 416 | # 417 | # Test refreshing a token, which will rewrite up to 3 cookies 418 | # 419 | echo '19. Testing POST to /refresh with correct secure details ...' 420 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 421 | -H "origin: $WEB_BASE_URL" \ 422 | -H 'content-type: application/json' \ 423 | -H 'accept: application/json' \ 424 | -H "x-example-csrf: $CSRF" \ 425 | -b $MAIN_COOKIES_FILE \ 426 | -c $MAIN_COOKIES_FILE \ 427 | -o $RESPONSE_FILE -w '%{http_code}') 428 | if [ "$HTTP_STATUS" != '204' ]; then 429 | echo "*** Refresh request failed with status $HTTP_STATUS" 430 | JSON=$(tail -n 1 $RESPONSE_FILE) 431 | echo $JSON | jq 432 | exit 433 | fi 434 | echo '19. POST to /refresh with correct secure details completed successfully' 435 | 436 | # 437 | # Test refreshing a token again, to ensure that the new refresh token is used for the refresh 438 | # 439 | echo '20. Testing POST to /refresh with rotated refresh token ...' 440 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/refresh" \ 441 | -H "origin: $WEB_BASE_URL" \ 442 | -H 'content-type: application/json' \ 443 | -H 'accept: application/json' \ 444 | -H "x-example-csrf: $CSRF" \ 445 | -b $MAIN_COOKIES_FILE \ 446 | -o $RESPONSE_FILE -w '%{http_code}') 447 | if [ "$HTTP_STATUS" != '204' ]; then 448 | echo "*** Refresh request failed with status $HTTP_STATUS" 449 | JSON=$(tail -n 1 $RESPONSE_FILE) 450 | echo $JSON | jq 451 | exit 452 | fi 453 | echo '20. POST to /refresh with rotated refresh token completed successfully' 454 | 455 | # 456 | # Test logging out with an invalid origin 457 | # 458 | echo '21. Testing logout POST with invalid web origin ...' 459 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/logout" \ 460 | -H "origin: http://malicious-site.com" \ 461 | -H 'content-type: application/json' \ 462 | -H 'accept: application/json' \ 463 | -d '{"pageUrl":"'$WEB_BASE_URL'"}' \ 464 | -o $RESPONSE_FILE -w '%{http_code}') 465 | if [ "$HTTP_STATUS" != '401' ]; then 466 | echo '*** Invalid logout request did not fail as expected' 467 | exit 468 | fi 469 | JSON=$(tail -n 1 $RESPONSE_FILE) 470 | echo $JSON | jq 471 | CODE=$(jq -r .code <<< "$JSON") 472 | if [ "$CODE" != 'unauthorized_request' ]; then 473 | echo "*** Logout returned an unexpected error code" 474 | exit 475 | fi 476 | echo '21. POST to logout with an invalid web origin was successfully rejected' 477 | 478 | # 479 | # Test logging out without a cookie 480 | # 481 | echo '22. Testing logout POST without secure cookies ...' 482 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/logout" \ 483 | -H "origin: $WEB_BASE_URL" \ 484 | -H 'content-type: application/json' \ 485 | -H 'accept: application/json' \ 486 | -d '{"pageUrl":"'$WEB_BASE_URL'"}' \ 487 | -o $RESPONSE_FILE -w '%{http_code}') 488 | if [ "$HTTP_STATUS" != '401' ]; then 489 | echo '*** Invalid logout request did not fail as expected' 490 | exit 491 | fi 492 | JSON=$(tail -n 1 $RESPONSE_FILE) 493 | echo $JSON | jq 494 | CODE=$(jq -r .code <<< "$JSON") 495 | if [ "$CODE" != 'unauthorized_request' ]; then 496 | echo "*** Logout returned an unexpected error code" 497 | exit 498 | fi 499 | echo '22. POST to logout without secure cookies was successfully rejected' 500 | 501 | # 502 | # Test logging out without an incorrect anti forgery token 503 | # 504 | echo '23. Testing logout POST with incorrect anti forgery token ...' 505 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/logout" \ 506 | -H "origin: $WEB_BASE_URL" \ 507 | -H 'content-type: application/json' \ 508 | -H 'accept: application/json' \ 509 | -H "x-example-csrf: abc123" \ 510 | -b $MAIN_COOKIES_FILE \ 511 | -o $RESPONSE_FILE -w '%{http_code}') 512 | if [ "$HTTP_STATUS" != '401' ]; then 513 | echo '*** Invalid logout request did not fail as expected' 514 | exit 515 | fi 516 | JSON=$(tail -n 1 $RESPONSE_FILE) 517 | echo $JSON | jq 518 | CODE=$(jq -r .code <<< "$JSON") 519 | if [ "$CODE" != 'unauthorized_request' ]; then 520 | echo "*** Logout returned an unexpected error code" 521 | exit 522 | fi 523 | echo '23. POST to logout with incorrect anti forgery token was successfully rejected' 524 | 525 | # 526 | # Test getting the logout URL and clearing cookies successfully 527 | # 528 | echo '24. Testing logout POST with correct secure details ...' 529 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/logout" \ 530 | -H "origin: $WEB_BASE_URL" \ 531 | -H 'content-type: application/json' \ 532 | -H 'accept: application/json' \ 533 | -H "x-example-csrf: $CSRF" \ 534 | -b $MAIN_COOKIES_FILE \ 535 | -o $RESPONSE_FILE -w '%{http_code}') 536 | if [ "$HTTP_STATUS" != '200' ]; then 537 | echo "*** Logout request failed with status $HTTP_STATUS" 538 | exit 539 | fi 540 | echo '24. POST to logout with correct secure details completed successfully' 541 | JSON=$(tail -n 1 $RESPONSE_FILE) 542 | echo $JSON | jq 543 | 544 | # 545 | # Test following the end session redirect to sign out in the Curity Identity Server 546 | # 547 | echo '25. Testing following the end session redirect redirect ...' 548 | END_SESSION_REQUEST_URL=$(jq -r .url <<< "$JSON") 549 | HTTP_STATUS=$(curl -i -s -X GET $END_SESSION_REQUEST_URL \ 550 | -c $CURITY_COOKIES_FILE \ 551 | -o $RESPONSE_FILE -w '%{http_code}') 552 | if [ $HTTP_STATUS != '303' ]; then 553 | echo "*** Problem encountered during an OpenID Connect end session redirect, status: $HTTP_STATUS" 554 | exit 555 | fi 556 | echo '25. End session redirect completed successfully' 557 | 558 | # 559 | # Test sending malformed JSON which currently results in a 500 error 560 | # 561 | echo '26. Testing sending malformed JSON to the OAuth Agent ...' 562 | HTTP_STATUS=$(curl -i -s -X POST "$TOKEN_HANDLER_BASE_URL/login/end" \ 563 | -H "origin: $WEB_BASE_URL" \ 564 | -H 'content-type: application/json' \ 565 | -H 'accept: application/json' \ 566 | -d 'XXX' \ 567 | -o $RESPONSE_FILE -w '%{http_code}') 568 | if [ "$HTTP_STATUS" != '500' ]; then 569 | echo '*** Posting malformed JSON did not fail as expected' 570 | exit 571 | fi 572 | JSON=$(tail -n 1 $RESPONSE_FILE) 573 | echo $JSON | jq 574 | CODE=$(jq -r .code <<< "$JSON") 575 | if [ "$CODE" != 'server_error' ]; then 576 | echo '*** Malformed JSON post returned an unexpected error code' 577 | exit 578 | fi 579 | echo '26. Malformed JSON was handled in the expected manner' 580 | -------------------------------------------------------------------------------- /test/integration/claimsControllerTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import fetch from 'node-fetch'; 3 | import {config} from '../../src/config.js'; 4 | import {performLogin} from './testUtils.js' 5 | import {OAuthAgentClaimsResponse, OAuthAgentErrorResponse} from "./responses.js"; 6 | 7 | // Tests to focus on returning ID token details 8 | describe('ClaimsControllerTests', () => { 9 | 10 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 11 | 12 | it('Requesting claims from an untrusted origin should return a 401 response', async () => { 13 | 14 | const response = await fetch( 15 | `${oauthAgentBaseUrl}/claims`, 16 | { 17 | method: 'GET', 18 | headers: { 19 | origin: 'https://malicious-site.com', 20 | }, 21 | }, 22 | ) 23 | 24 | assert.equal(response.status, 401, 'Incorrect HTTP status') 25 | const body = await response.json() as OAuthAgentErrorResponse 26 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 27 | }) 28 | 29 | it('Requesting claims without session cookies should return a 401 response', async () => { 30 | 31 | const response = await fetch( 32 | `${oauthAgentBaseUrl}/claims`, 33 | { 34 | method: 'GET', 35 | headers: { 36 | origin: config.trustedWebOrigins[0], 37 | }, 38 | }, 39 | ) 40 | 41 | assert.equal(response.status, 401, 'Incorrect HTTP status') 42 | const body = await response.json() as OAuthAgentErrorResponse 43 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 44 | }) 45 | 46 | it('Requesting claims with valid cookies should return ID Token claims', async () => { 47 | 48 | const [, , cookieString] = await performLogin() 49 | const response = await fetch( 50 | `${oauthAgentBaseUrl}/claims`, 51 | { 52 | method: 'GET', 53 | headers: { 54 | origin: config.trustedWebOrigins[0], 55 | cookie: cookieString, 56 | }, 57 | }, 58 | ) 59 | 60 | assert.equal(response.status, 200, 'Incorrect HTTP status') 61 | const body = await response.json() as OAuthAgentClaimsResponse 62 | expect(body.auth_time.toString(), 'Missing auth_time claim').length.above(0) 63 | }) 64 | }) -------------------------------------------------------------------------------- /test/integration/extensibilityTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai' 2 | import fetch from 'node-fetch' 3 | import {config} from '../../src/config.js' 4 | import {OauthAgentStartResponse} from "./responses.js"; 5 | 6 | // Tests to focus on extra details the SPA may need to supply at runtime 7 | describe('ExtensibilityTests', () => { 8 | 9 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 10 | 11 | it('Starting a login request with a simple OpenID Connect parameter should include it in the request URL', async () => { 12 | 13 | const options = { 14 | extraParams: [ 15 | { 16 | key: 'prompt', 17 | value: 'login', 18 | }, 19 | ], 20 | } 21 | 22 | const response = await fetch( 23 | `${oauthAgentBaseUrl}/login/start`, 24 | { 25 | method: 'POST', 26 | headers: { 27 | origin: config.trustedWebOrigins[0], 28 | 'content-type': 'application/json', 29 | }, 30 | body: JSON.stringify(options), 31 | }, 32 | ) 33 | 34 | assert.equal(response.status, 200, 'Incorrect HTTP status') 35 | const body = await response.json() as OauthAgentStartResponse 36 | const authorizationRequestUrl = body.authorizationRequestUrl as string 37 | 38 | expect(authorizationRequestUrl).contains( 39 | `${options.extraParams[0].key}=${options.extraParams[0].value}`, 40 | 'The extra parameter was not added to the authorization request URL') 41 | }) 42 | 43 | it('Starting a login request with multiple OpenID Connect parameters should include them in the request URL', async () => { 44 | 45 | const claims = { 46 | id_token: { 47 | acr: { 48 | essential: true, 49 | values: [ 50 | "urn:se:curity:authentication:html-form:htmlform1" 51 | ] 52 | } 53 | } 54 | } 55 | const claimsText = JSON.stringify(claims) 56 | 57 | const options = { 58 | extraParams: [ 59 | { 60 | key: 'ui_locales', 61 | value: 'fr', 62 | }, 63 | { 64 | key: 'claims', 65 | value: claimsText, 66 | }, 67 | ], 68 | } 69 | 70 | const response = await fetch( 71 | `${oauthAgentBaseUrl}/login/start`, 72 | { 73 | method: 'POST', 74 | headers: { 75 | origin: config.trustedWebOrigins[0], 76 | 'content-type': 'application/json', 77 | }, 78 | body: JSON.stringify(options), 79 | }, 80 | ) 81 | 82 | assert.equal(response.status, 200, 'Incorrect HTTP status') 83 | const body = await response.json() as OauthAgentStartResponse 84 | const authorizationRequestUrl = body.authorizationRequestUrl as string 85 | 86 | options.extraParams.forEach((p: any) => { 87 | expect(authorizationRequestUrl).contains( 88 | `${p.key}=${encodeURIComponent(p.value)}`, 89 | 'The extra parameters were not added to the authorization request URL') 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /test/integration/loginControllerTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai' 2 | import fetch, {RequestInit} from 'node-fetch'; 3 | import {config} from '../../src/config.js' 4 | import {fetchStubbedResponse, performLogin, startLogin} from './testUtils.js' 5 | import {OAuthAgentEndResponse, OAuthAgentErrorResponse, OauthAgentStartResponse} from "./responses.js"; 6 | 7 | // Tests to focus on the login endpoint 8 | describe('LoginControllerTests', () => { 9 | 10 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 11 | 12 | it('Sending an OPTIONS request with wrong Origin should return 204 response without CORS headers', async () => { 13 | 14 | const response = await fetch( 15 | `${oauthAgentBaseUrl}/login/start`, 16 | { 17 | method: 'OPTIONS', 18 | headers: { 19 | origin: 'https://malicious-site.com', 20 | }, 21 | }, 22 | ) 23 | 24 | assert.equal(response.status, 204, 'Incorrect HTTP status') 25 | assert.equal(response.headers.get('access-control-allow-origin'), null, 'Incorrect allowed origin'); 26 | }) 27 | 28 | it('Sending OPTIONS request with a valid web origin should return a 204 response with proper CORS headers', async () => { 29 | 30 | const response = await fetch( 31 | `${oauthAgentBaseUrl}/login/start`, 32 | { 33 | method: 'OPTIONS', 34 | headers: { 35 | origin: config.trustedWebOrigins[0], 36 | }, 37 | }, 38 | ) 39 | 40 | assert.equal(response.status, 204, 'Incorrect HTTP status') 41 | assert.equal(response.headers.get('access-control-allow-origin'), config.trustedWebOrigins[0], 'Incorrect allowed origin'); 42 | }) 43 | 44 | it('Request to end login with invalid web origin should return 401 response', async () => { 45 | 46 | const payload = { 47 | pageUrl: 'http://www.example.com' 48 | } 49 | const response = await fetch( 50 | `${oauthAgentBaseUrl}/login/end`, 51 | { 52 | method: 'POST', 53 | headers: { 54 | origin: 'https://malicious-site.com', 55 | }, 56 | body: JSON.stringify(payload), 57 | }, 58 | ) 59 | 60 | assert.equal(response.status, 401, 'Incorrect HTTP status') 61 | const body = await response.json() as OAuthAgentErrorResponse 62 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 63 | }) 64 | 65 | it('Request to end login should return correct unauthenticated response', async () => { 66 | 67 | const payload = { 68 | pageUrl: 'http://www.example.com' 69 | } 70 | const response = await fetch( 71 | `${oauthAgentBaseUrl}/login/end`, 72 | { 73 | method: 'POST', 74 | headers: { 75 | origin: config.trustedWebOrigins[0], 76 | }, 77 | body: JSON.stringify(payload), 78 | }, 79 | ) 80 | 81 | assert.equal(response.status, 200, 'Incorrect HTTP status') 82 | const body = await response.json() as OAuthAgentEndResponse 83 | assert.equal(body.isLoggedIn, false, 'Incorrect isLoggedIn value') 84 | assert.equal(body.handled, false, 'Incorrect handled value') 85 | }) 86 | 87 | it('POST request to start login with invalid web origin should return a 401 response', async () => { 88 | 89 | const response = await fetch( 90 | `${oauthAgentBaseUrl}/login/start`, 91 | { 92 | method: 'POST', 93 | headers: { 94 | origin: 'https://malicious-site.com', 95 | }, 96 | }, 97 | ) 98 | 99 | assert.equal(response.status, 401, 'Incorrect HTTP status') 100 | const body = await response.json() as OAuthAgentErrorResponse 101 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 102 | }) 103 | 104 | it('Request to start login should return authorization request URL', async () => { 105 | 106 | const response = await fetch( 107 | `${oauthAgentBaseUrl}/login/start`, 108 | { 109 | method: 'POST', 110 | headers: { 111 | origin: config.trustedWebOrigins[0], 112 | }, 113 | }, 114 | ) 115 | 116 | assert.equal(response.status, 200, 'Incorrect HTTP status') 117 | const body = await response.json() as OauthAgentStartResponse 118 | const authorizationRequestUrl = body.authorizationRequestUrl as string 119 | expect(authorizationRequestUrl).contains(`client_id=${config.clientID}`, 'Invalid authorization request URL') 120 | }) 121 | 122 | it('Posting a code flow response to login end should result in authenticating the user', async () => { 123 | 124 | const [status, body, cookieString] = await performLogin() 125 | 126 | assert.equal(status, 200, 'Incorrect HTTP status') 127 | expect(cookieString, 'Missing secure cookies').length.above(0) 128 | assert.equal(body.isLoggedIn, true, 'Incorrect isLoggedIn value') 129 | assert.equal(body.handled, true, 'Incorrect handled value') 130 | expect(body.csrf, 'Missing csrfToken value').length.above(0) 131 | }) 132 | 133 | it('Posting a code flow response with malicous state to login end should return a 400 invalid_request response', async () => { 134 | 135 | const [status, body] = await performLogin('ad0316c6-b4e8-11ec-b909-0242ac120002') 136 | 137 | assert.equal(status, 400, 'Incorrect HTTP status') 138 | assert.equal(body.code, 'invalid_request', 'Incorrect error code') 139 | }) 140 | 141 | it("Posting to end login with session cookies should return proper 200 response", async () => { 142 | 143 | const [, , cookieString] = await performLogin() 144 | 145 | const payload = { 146 | pageUrl: 'http://www.example.com', 147 | } 148 | const response = await fetch( 149 | `${oauthAgentBaseUrl}/login/end`, 150 | { 151 | method: 'POST', 152 | headers: { 153 | origin: config.trustedWebOrigins[0], 154 | cookie: cookieString, 155 | }, 156 | body: JSON.stringify(payload), 157 | }, 158 | ) 159 | 160 | assert.equal(response.status, 200, 'Incorrect HTTP status') 161 | const body = await response.json() as OAuthAgentEndResponse 162 | assert.equal(body.isLoggedIn, true, 'Incorrect isLoggedIn value') 163 | assert.equal(body.handled, false, 'Incorrect handled value') 164 | expect(body.csrf, 'Missing csrfToken value').length.above(0) 165 | }) 166 | 167 | it('An incorrectly configured client secret should return a 400', async () => { 168 | 169 | const [state, cookieString] = await startLogin() 170 | const code = '4a4246d6-b4bd-11ec-b909-0242ac120002' 171 | 172 | const payload = { 173 | pageUrl: `http://www.example.com?code=${code}&state=${state}`, 174 | } 175 | const options = { 176 | method: 'POST', 177 | headers: { 178 | origin: config.trustedWebOrigins[0], 179 | 'Content-Type': 'application/json', 180 | cookie: cookieString, 181 | }, 182 | body: JSON.stringify(payload), 183 | } as RequestInit 184 | 185 | const stubbedResponse = { 186 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 187 | priority: 1, 188 | request: { 189 | method: 'POST', 190 | url: '/oauth/v2/oauth-token' 191 | }, 192 | response: { 193 | 194 | // Simulate the response for an incorrect client secret to complete the OIDC flow 195 | status: 400, 196 | body: "{\"error\":\"invalid_client\"}" 197 | } 198 | } 199 | 200 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 201 | return await fetch(`${oauthAgentBaseUrl}/login/end`, options) 202 | }) 203 | 204 | // Return a 400 to the SPA, as opposed to a 401, which could cause a redirect loop 205 | assert.equal(response.status, 400, 'Incorrect HTTP status') 206 | const body = await response.json() as OAuthAgentErrorResponse 207 | assert.equal(body.code, 'authorization_error', 'Incorrect error code') 208 | }) 209 | 210 | it('An incorrectly configured SPA should report front channel errors correctly', async () => { 211 | 212 | const [state, cookieString] = await startLogin() 213 | 214 | const payload = { 215 | pageUrl: `http://www.example.com?error=invalid_scope&state=${state}`, 216 | } 217 | const options = { 218 | method: 'POST', 219 | headers: { 220 | origin: config.trustedWebOrigins[0], 221 | 'Content-Type': 'application/json', 222 | cookie: cookieString, 223 | }, 224 | body: JSON.stringify(payload), 225 | } as RequestInit 226 | 227 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 228 | 229 | assert.equal(response.status, 400, 'Incorrect HTTP status') 230 | const body = await response.json() as OAuthAgentErrorResponse 231 | assert.equal(body.code, 'invalid_scope', 'Incorrect error code') 232 | }) 233 | 234 | it('The SPA should receive a 401 for expiry related front channel errors', async () => { 235 | 236 | const clientOptions = { 237 | extraParams: [ 238 | { 239 | key: 'prompt', 240 | value: 'none', 241 | } 242 | ] 243 | } 244 | const [state, cookieString] = await startLogin(clientOptions) 245 | 246 | const payload = { 247 | pageUrl: `http://www.example.com?error=login_required&state=${state}`, 248 | } 249 | const options = { 250 | method: 'POST', 251 | headers: { 252 | origin: config.trustedWebOrigins[0], 253 | 'Content-Type': 'application/json', 254 | cookie: cookieString, 255 | }, 256 | body: JSON.stringify(payload), 257 | } as RequestInit 258 | 259 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 260 | 261 | assert.equal(response.status, 401, 'Incorrect HTTP status') 262 | const body = await response.json() as OAuthAgentErrorResponse 263 | assert.equal(body.code, 'login_required', 'Incorrect error code') 264 | }) 265 | }) 266 | -------------------------------------------------------------------------------- /test/integration/logoutControllerTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai' 2 | import fetch, {RequestInit} from 'node-fetch' 3 | import {config} from '../../src/config.js' 4 | import {getCookieString, performLogin} from './testUtils.js' 5 | import {OAuthAgentErrorResponse, OAuthAgentLogoutResponse} from "./responses.js"; 6 | 7 | // Tests to focus on the logout endpoint 8 | describe('LogoutControllerTests', () => { 9 | 10 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 11 | 12 | it('Posting to logout from a malicious origin should return a 401 response', async () => { 13 | 14 | const response = await fetch( 15 | `${oauthAgentBaseUrl}/logout`, 16 | { 17 | method: 'POST', 18 | headers: { 19 | origin: 'https://malicious-site.com', 20 | }, 21 | }, 22 | ) 23 | 24 | assert.equal(response.status, 401, 'Incorrect HTTP status') 25 | const body = await response.json() as OAuthAgentErrorResponse 26 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 27 | }) 28 | 29 | it('Posting to logout without cookies should return a 401 response', async () => { 30 | 31 | const response = await fetch( 32 | `${oauthAgentBaseUrl}/logout`, 33 | { 34 | method: 'POST', 35 | headers: { 36 | origin: config.trustedWebOrigins[0], 37 | }, 38 | }, 39 | ) 40 | 41 | assert.equal(response.status, 401, 'Incorrect HTTP status') 42 | const body = await response.json() as OAuthAgentErrorResponse 43 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 44 | }) 45 | 46 | it('Posting incorrect CSRF token to logout should return a 401 response', async () => { 47 | 48 | const [, , cookieString] = await performLogin() 49 | 50 | const options = { 51 | method: 'POST', 52 | headers: { 53 | origin: config.trustedWebOrigins[0], 54 | 'Content-Type': 'application/json', 55 | cookie: cookieString, 56 | }, 57 | } as RequestInit 58 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = 'abc123' 59 | 60 | const response = await fetch(`${oauthAgentBaseUrl}/logout`, options) 61 | 62 | assert.equal(response.status, 401, 'Incorrect HTTP status') 63 | const body = await response.json() as OAuthAgentErrorResponse 64 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 65 | }) 66 | 67 | it("Posting to logout with correct session cookies should return a 200 response and clear cookies", async () => { 68 | 69 | const [, loginBody, cookieString] = await performLogin() 70 | const options = { 71 | method: 'POST', 72 | headers: { 73 | origin: config.trustedWebOrigins[0], 74 | 'Content-Type': 'application/json', 75 | cookie: cookieString, 76 | }, 77 | } as RequestInit 78 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = loginBody['csrf'] 79 | 80 | const response = await fetch(`${oauthAgentBaseUrl}/logout`, options) 81 | 82 | assert.equal(response.status, 200, 'Incorrect HTTP status') 83 | const body = await response.json() as OAuthAgentLogoutResponse 84 | const endSessionRequestUrl = body.url as string 85 | expect(endSessionRequestUrl).contains(`client_id=${config.clientID}`, 'Invalid end session request URL') 86 | 87 | const clearedCookies = getCookieString(response); 88 | assert.equal(clearedCookies, "example-auth=;example-at=;example-id=;example-csrf=;", 'Incorrect cleared cookies string') 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/integration/mappings/authorization-code-grant-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "method": "POST", 4 | "url": "/oauth/v2/oauth-token" 5 | }, 6 | "response": { 7 | "status": 200, 8 | "body": "{\"access_token\":\"_0XBPWQQ_2fe74f4b-68b9-4128-8e75-d738b34dbce2\",\"refresh_token\":\"_1XBPWQQ_ae0ea3f2-a0bc-48e2-a216-cb8b650670cd\",\"id_token\":\"eyJraWQiOiI2NTU4NTI4NzgiLCJ4NXQiOiJOWGRLQ1NWMjlTQ2k4c05Nb1F1ZzRpY093bWsiLCJhbGciOiJSUzI1NiJ9.eyJleHAiOjE2ODkyNDE4NzgsIm5iZiI6MTY4OTIzODI3OCwianRpIjoiMTY1NzE0NmItNWYzMC00YTkwLWIwOWItNTE0NzZiNWZkZTYzIiwiaXNzIjoiaHR0cDovL2xvZ2luLmV4YW1wbGUubG9jYWw6ODQ0My9vYXV0aC92Mi9vYXV0aC1hbm9ueW1vdXMiLCJhdWQiOlsic3BhLWNsaWVudCIsImFwaS5leGFtcGxlLmxvY2FsIl0sInN1YiI6IjBhYmQwYjE2YjMwOWEzYTAzNGFmODQ5NGFhMDA5MmFhNDI4MTNlNjM1ZjE5NGM3OTVkZjUwMDZkYjkwNzQzZTgiLCJhdXRoX3RpbWUiOjE2ODkyMzgyNzgsImlhdCI6MTY4OTIzODI3OCwicHVycG9zZSI6ImlkIiwiYXRfaGFzaCI6IkliZTJ4ZXVNaTZ2QkJPazFEbFA5Y2ciLCJhY3IiOiJ1cm46c2U6Y3VyaXR5OmF1dGhlbnRpY2F0aW9uOmh0bWwtZm9ybTpVc2VybmFtZS1QYXNzd29yZCIsImRlbGVnYXRpb25faWQiOiIxMmY0ODM1ZS1lZTQ3LTQ3YjYtYjYzOC04NTc5Y2NmMTNhZWIiLCJzX2hhc2giOiJuUDBJMDF5VWRtdmZEQkVGZXZHS3BRIiwiYXpwIjoic3BhLWNsaWVudCIsImFtciI6InVybjpzZTpjdXJpdHk6YXV0aGVudGljYXRpb246aHRtbC1mb3JtOlVzZXJuYW1lLVBhc3N3b3JkIiwic2lkIjoiUHQyTndFRWQ3eUxhdFkwNSJ9.c3nYUjQeUFOiI29ud-DUDLkhv8L3vHtyCZdLMeGarahLbvLlVtwB_NCtglEa8bnCfCNZt9uP_RHXFsTYJDj9o6qXPF2fukIc05hPXqTWd1WoXjIf6_SUFC4bF9UWBLMumX4v0GZQ7Ps_VG2OGKlzUgaw1C9ljymh3JTUg2WlfvNbgGcdd4rJsPFZbp0kJOx-rgPwlvlCQxHak2NAJu1MXpLYSwq0Cbex7i492bq0_5yeNwFsCbEG8nRAG1YlCr7T5RGm_UGuKhmhLyG-3HKG7y2ssFgw47e8ogW7y6JCOANPuVsZfgo0vjNRqIEjOKvEhhoYa265BC5iLiZkoY99EA\"}" 9 | } 10 | } -------------------------------------------------------------------------------- /test/integration/mappings/userinfo-mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "request": { 3 | "method": "POST", 4 | "url": "/oauth/v2/oauth-userinfo" 5 | }, 6 | "response": { 7 | "status": 200, 8 | "body": "{\"sub\":\"0abd0b16b309a3a034af8494aa0092aa42813e635f194c795df5006db90743e8\", \"preferred_username\":\"demouser\", \"given_name\":\"Demo\", \"family_name\":\"User\"}" 9 | } 10 | } -------------------------------------------------------------------------------- /test/integration/refreshTokenControllerTests.ts: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai' 2 | import fetch, {RequestInit} from 'node-fetch' 3 | import {config} from '../../src/config.js' 4 | import {fetchStubbedResponse, getCookieString, performLogin} from './testUtils.js' 5 | import {OAuthAgentErrorResponse} from "./responses.js"; 6 | 7 | // Tests to focus on token refresh when access tokens expire 8 | describe('RefreshTokenControllerTests', () => { 9 | 10 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 11 | 12 | it('Sending POST request to refresh endpoint from untrusted origin should return a 401 response', async () => { 13 | 14 | const response = await fetch( 15 | `${oauthAgentBaseUrl}/refresh`, 16 | { 17 | method: 'POST', 18 | headers: { 19 | origin: 'https://malicious-site.com', 20 | }, 21 | }, 22 | ) 23 | 24 | assert.equal(response.status, 401, 'Incorrect HTTP status') 25 | const body = await response.json() as OAuthAgentErrorResponse 26 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 27 | }) 28 | 29 | it('Sending POST request to refresh endpoint without session cookies should return a 401 response', async () => { 30 | 31 | const response = await fetch( 32 | `${oauthAgentBaseUrl}/refresh`, 33 | { 34 | method: 'POST', 35 | headers: { 36 | origin: config.trustedWebOrigins[0], 37 | }, 38 | }, 39 | ) 40 | 41 | assert.equal(response.status, 401, 'Incorrect HTTP status') 42 | const body = await response.json() as OAuthAgentErrorResponse 43 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 44 | }) 45 | 46 | it('Posting to refresh endpoint with incorrect CSRF token should return a 401 response', async () => { 47 | 48 | const [, , cookieString] = await performLogin() 49 | 50 | const options = { 51 | method: 'POST', 52 | headers: { 53 | origin: config.trustedWebOrigins[0], 54 | 'Content-Type': 'application/json', 55 | cookie: cookieString, 56 | }, 57 | } as RequestInit 58 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = 'abc123' 59 | 60 | const response = await fetch(`${oauthAgentBaseUrl}/refresh`, options) 61 | 62 | assert.equal(response.status, 401, 'Incorrect HTTP status') 63 | const body = await response.json() as OAuthAgentErrorResponse 64 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 65 | }) 66 | 67 | it("Posting correct cookies to refresh endpoint should return a new set of cookies", async () => { 68 | 69 | const [, loginBody, cookieString] = await performLogin() 70 | 71 | const options = { 72 | method: 'POST', 73 | headers: { 74 | origin: config.trustedWebOrigins[0], 75 | 'Content-Type': 'application/json', 76 | cookie: cookieString, 77 | }, 78 | } as RequestInit 79 | 80 | const customHeaders = options.headers as any 81 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 82 | 83 | const response = await fetch(`${oauthAgentBaseUrl}/refresh`, options) 84 | 85 | assert.equal(response.status, 204, 'Incorrect HTTP status') 86 | const rewrittenCookieString = getCookieString(response) 87 | expect(rewrittenCookieString, 'Missing secure cookies').length.above(0) 88 | assert.notEqual(rewrittenCookieString, cookieString) 89 | }) 90 | 91 | it("A configuration error rejected by the Authorization Server when refreshing tokens should result in a 400 status code", async () => { 92 | 93 | const [, loginBody, cookieString] = await performLogin() 94 | 95 | const options = { 96 | method: 'POST', 97 | headers: { 98 | origin: config.trustedWebOrigins[0], 99 | 'Content-Type': 'application/json', 100 | cookie: cookieString, 101 | }, 102 | } as RequestInit 103 | 104 | const customHeaders = options.headers as any 105 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 106 | 107 | const stubbedResponse = { 108 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 109 | priority: 1, 110 | request: { 111 | method: 'POST', 112 | url: '/oauth/v2/oauth-token' 113 | }, 114 | response: { 115 | status: 400, 116 | body: "{\"error\":\"invalid_client\"}" 117 | } 118 | } 119 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 120 | return await fetch(`${oauthAgentBaseUrl}/refresh`, options) 121 | }) 122 | 123 | // The SPA cannot recover from this error so would need to present an error display 124 | assert.equal(response.status, 400, 'Incorrect HTTP status') 125 | const body = await response.json() as OAuthAgentErrorResponse 126 | assert.equal(body.code, 'authorization_error', 'Incorrect error code') 127 | }) 128 | 129 | it("An expired refresh token should result in a 401 response and cleared cookies", async () => { 130 | 131 | const [, loginBody, cookieString] = await performLogin() 132 | 133 | const options = { 134 | method: 'POST', 135 | headers: { 136 | origin: config.trustedWebOrigins[0], 137 | 'Content-Type': 'application/json', 138 | cookie: cookieString, 139 | }, 140 | } as RequestInit 141 | 142 | const customHeaders = options.headers as any 143 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 144 | 145 | const stubbedResponse = { 146 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 147 | priority: 1, 148 | request: { 149 | method: 'POST', 150 | 'url': '/oauth/v2/oauth-token' 151 | }, 152 | response: { 153 | status: 401, 154 | 155 | // In a correct setup this will be returned from the Authorization Server when the refresh token expires 156 | body: "{\"error\":\"invalid_grant\"}" 157 | } 158 | } 159 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 160 | return await fetch(`${oauthAgentBaseUrl}/refresh`, options) 161 | }) 162 | 163 | // The SPA will trigger re-authentication when it gets a 401 during token refresh 164 | assert.equal(response.status, 401, 'Incorrect HTTP status') 165 | const body = await response.json() as OAuthAgentErrorResponse 166 | assert.equal(body.code, 'session_expired', 'Incorrect error code') 167 | 168 | // Clear cookies so that the next call to /login/end, eg a page reload, indicates not logged in 169 | const clearedCookies = getCookieString(response); 170 | assert.equal(clearedCookies, "example-auth=;example-at=;example-id=;example-csrf=;", 'Incorrect cleared cookies string') 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /test/integration/responses.ts: -------------------------------------------------------------------------------- 1 | export interface OauthAgentStartResponse { 2 | authorizationRequestUrl: string 3 | } 4 | 5 | export interface OAuthAgentErrorResponse { 6 | code: string 7 | } 8 | 9 | export interface OAuthAgentClaimsResponse { 10 | auth_time: number 11 | } 12 | 13 | export interface OAuthAgentEndResponse { 14 | isLoggedIn: boolean 15 | handled: boolean 16 | csrf: string 17 | } 18 | 19 | export interface OAuthAgentLogoutResponse { 20 | url: string 21 | } 22 | 23 | export interface OAuthAgentUserinfoResponse { 24 | given_name: string 25 | family_name: string 26 | } -------------------------------------------------------------------------------- /test/integration/testUtils.ts: -------------------------------------------------------------------------------- 1 | 2 | import fetch, {RequestInit, Response} from 'node-fetch'; 3 | import setCookie from 'set-cookie-parser'; 4 | import urlParse from 'url-parse'; 5 | import {config} from '../../src/config.js'; 6 | import { ClientOptions } from '../../src/lib/clientOptions.js'; 7 | import {OauthAgentStartResponse} from "./responses.js"; 8 | 9 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 10 | const wiremockAdminBaseUrl = `http://localhost:8443/__admin/mappings` 11 | 12 | /* 13 | * Do a complete login, including ending the login and getting cookies 14 | */ 15 | export async function performLogin(stateOverride: string = ''): Promise<[number, any, string]> { 16 | 17 | const [state, loginCookieString] = await startLogin() 18 | const code = '4a4246d6-b4bd-11ec-b909-0242ac120002' 19 | const payload = { 20 | pageUrl: `${oauthAgentBaseUrl}?code=${code}&state=${stateOverride || state}` 21 | } 22 | 23 | const options = { 24 | method: 'POST', 25 | headers: { 26 | origin: config.trustedWebOrigins[0], 27 | 'Content-Type': 'application/json', 28 | cookie: loginCookieString, 29 | }, 30 | body: JSON.stringify(payload), 31 | } as RequestInit 32 | 33 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 34 | const body = await response.json() 35 | 36 | const cookieString = getCookieString(response) 37 | return [response.status, body, cookieString] 38 | } 39 | 40 | /* 41 | * Get a response cookie in the form where it can be sent in subsequent requests 42 | */ 43 | export function getCookieString(response: Response) { 44 | 45 | const rawCookies = response.headers.raw()['set-cookie'] 46 | const cookies = setCookie.parse(rawCookies) 47 | 48 | let allCookiesString = ''; 49 | cookies.forEach((c) => { 50 | allCookiesString += `${c.name}=${c.value};` 51 | }) 52 | 53 | return allCookiesString 54 | } 55 | 56 | /* 57 | * Do a fetch with a stubbed response, dealing with adding the stub to wiremock and then deleting it 58 | */ 59 | export async function fetchStubbedResponse(stubbedResponse: any, fetchAction: () => Promise): Promise { 60 | 61 | try { 62 | await addStub(stubbedResponse) 63 | return await fetchAction() 64 | 65 | } finally { 66 | await deleteStub(stubbedResponse.id) 67 | } 68 | } 69 | 70 | /* 71 | * Do the work to start a login and get the temp cookie 72 | */ 73 | export async function startLogin(requestBody: ClientOptions | null = null): Promise<[string, string]> { 74 | 75 | const requestOptions = { 76 | method: 'POST', 77 | headers: { 78 | origin: config.trustedWebOrigins[0], 79 | }, 80 | } as RequestInit 81 | 82 | if (requestBody) { 83 | requestOptions.body = JSON.stringify(requestBody) 84 | } 85 | 86 | const response = await fetch(`${oauthAgentBaseUrl}/login/start`, requestOptions) 87 | 88 | const responseBody = await response.json() as OauthAgentStartResponse; 89 | const parsedUrl = urlParse(responseBody.authorizationRequestUrl, true) 90 | const state = parsedUrl.query.state 91 | 92 | const cookieString = getCookieString(response) 93 | return [state!, cookieString] 94 | } 95 | 96 | /* 97 | * Add a stubbed response to Wiremock via its Admin API 98 | */ 99 | async function addStub(stubbedResponse: any): Promise { 100 | 101 | const options = { 102 | method: 'POST', 103 | headers: { 104 | 'Content-Type': 'application/json', 105 | }, 106 | body: JSON.stringify(stubbedResponse), 107 | } as RequestInit 108 | 109 | const response = await fetch(wiremockAdminBaseUrl, options) 110 | if (response.status !== 201) { 111 | const responseData = await response.text() 112 | console.log(responseData) 113 | throw new Error('Failed to add Wiremock stub') 114 | } 115 | } 116 | 117 | /* 118 | * Delete a stubbed response to Wiremock via its Admin API 119 | */ 120 | async function deleteStub(id: string): Promise { 121 | 122 | const response = await fetch(`${wiremockAdminBaseUrl}/${id}`, {method: 'DELETE'}) 123 | if (response.status !== 200) { 124 | const responseData = await response.text() 125 | console.log(responseData) 126 | throw new Error('Failed to delete Wiremock stub') 127 | } 128 | } -------------------------------------------------------------------------------- /test/integration/userInfoControllerTests.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import fetch, {RequestInit} from 'node-fetch'; 3 | import {config} from '../../src/config.js'; 4 | import {fetchStubbedResponse, performLogin} from './testUtils.js' 5 | import {OAuthAgentErrorResponse, OAuthAgentUserinfoResponse} from "./responses.js"; 6 | 7 | // Tests to focus on returning user information to the SPA via the user info endpoint 8 | describe('UserInfoControllerTests', () => { 9 | 10 | const oauthAgentBaseUrl = `http://localhost:${config.port}${config.endpointsPrefix}` 11 | 12 | it('Requesting user info from an untrusted origin should return a 401 response', async () => { 13 | 14 | const response = await fetch( 15 | `${oauthAgentBaseUrl}/userInfo`, 16 | { 17 | method: 'GET', 18 | headers: { 19 | origin: 'https://malicious-site.com', 20 | }, 21 | }, 22 | ) 23 | 24 | assert.equal(response.status, 401, 'Incorrect HTTP status') 25 | const body = await response.json() as OAuthAgentErrorResponse 26 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 27 | }) 28 | 29 | it('Requesting user info without session cookies should return a 401 response', async () => { 30 | 31 | const response = await fetch( 32 | `${oauthAgentBaseUrl}/userInfo`, 33 | { 34 | method: 'GET', 35 | headers: { 36 | origin: config.trustedWebOrigins[0], 37 | }, 38 | }, 39 | ) 40 | 41 | assert.equal(response.status, 401, 'Incorrect HTTP status') 42 | const body = await response.json() as OAuthAgentErrorResponse 43 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 44 | }) 45 | 46 | it('Requesting user info with valid cookies should return user data', async () => { 47 | 48 | const [, , cookieString] = await performLogin() 49 | const response = await fetch( 50 | `${oauthAgentBaseUrl}/userInfo`, 51 | { 52 | method: 'GET', 53 | headers: { 54 | origin: config.trustedWebOrigins[0], 55 | cookie: cookieString, 56 | }, 57 | }, 58 | ) 59 | 60 | assert.equal(response.status, 200, 'Incorrect HTTP status') 61 | const body = await response.json() as OAuthAgentUserinfoResponse 62 | assert.equal(body.given_name, 'Demo') 63 | assert.equal(body.family_name, 'User') 64 | }) 65 | 66 | it("An expired access token when retrieving user info should return a 401 status so that the SPA knows to try a token refresh", async () => { 67 | 68 | const [, , cookieString] = await performLogin() 69 | 70 | const options = { 71 | method: 'GET', 72 | headers: { 73 | origin: config.trustedWebOrigins[0], 74 | cookie: cookieString, 75 | }, 76 | } as RequestInit 77 | 78 | const stubbedResponse = { 79 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 80 | priority: 1, 81 | request: { 82 | method: 'POST', 83 | url: '/oauth/v2/oauth-userinfo' 84 | }, 85 | response: { 86 | 87 | // This will be returned from the Authorization Server if the access token is expired during a userinfo request 88 | status: 401, 89 | body: "{\"error\":\"invalid_token\"}" 90 | } 91 | } 92 | 93 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 94 | return await fetch(`${oauthAgentBaseUrl}/userInfo`, options) 95 | }) 96 | 97 | // The SPA will trigger token refresh when the OAuth Agent reports an expired access token 98 | assert.equal(response.status, 401, 'Incorrect HTTP status') 99 | const body = await response.json() as OAuthAgentErrorResponse 100 | assert.equal(body.code, 'token_expired', 'Incorrect error code') 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "sourceMap": true, 5 | "target": "ES2022", 6 | "outDir": "./dist", 7 | "baseUrl": "./src", 8 | "module": "ES2022", 9 | "moduleResolution": "Node", 10 | "allowSyntheticDefaultImports": true 11 | }, 12 | "include": [ 13 | "src/**/*.ts" 14 | ], 15 | "exclude": [ 16 | "node_modules" 17 | ] 18 | } 19 | --------------------------------------------------------------------------------