├── doc ├── logical-components.png ├── Setup.md └── Architecture.md ├── next-env.d.ts ├── test └── integration │ ├── mappings │ ├── userinfo-mapping.json │ └── authorization-code-grant-mapping.json │ ├── claimsControllerTests.ts │ ├── extensibilityTests.ts │ ├── logoutControllerTests.ts │ ├── userInfoControllerTests.ts │ ├── testUtils.ts │ ├── refreshTokenControllerTests.ts │ └── loginControllerTests.ts ├── Dockerfile ├── .gitignore ├── tsconfig.json ├── src ├── lib │ ├── extraParams.ts │ ├── clientOptions.ts │ ├── redirectUri.ts │ ├── exceptions │ │ ├── OAuthAgentException.ts │ │ ├── InvalidStateException.ts │ │ ├── MissingCodeVerifierException.ts │ │ ├── MethodNotAllowedException.ts │ │ ├── InvalidIDTokenException.ts │ │ ├── UnhandledException.ts │ │ ├── InvalidCookieException.ts │ │ ├── UnauthorizedException.ts │ │ ├── CookieDecryptionException.ts │ │ ├── AuthorizationServerException.ts │ │ ├── AuthorizationResponseException.ts │ │ ├── AuthorizationClientException.ts │ │ └── index.ts │ ├── getLogoutURL.ts │ ├── cookieName.ts │ ├── idTokenValidator.ts │ ├── getIDTokenClaims.ts │ ├── index.ts │ ├── authorizationURL.ts │ ├── pkce.ts │ ├── getUserInfo.ts │ ├── validateRequest.ts │ ├── cookieBuilder.ts │ ├── oauthAgentConfiguration.ts │ ├── cookieEncrypter.ts │ └── getToken.ts ├── OauthAgentResponse.ts ├── middleware │ ├── handleCathingWithLoggingAndCors.ts │ ├── runMiddleware.ts │ ├── loggingMiddleware.ts │ ├── handleException.ts │ └── requestLog.ts ├── validateNextRequest.ts └── config.ts ├── package.json ├── pages └── api │ ├── login │ ├── start.ts │ └── end.ts │ ├── logout.ts │ ├── claims.ts │ ├── userInfo.ts │ └── refresh.ts ├── README.md └── LICENSE /doc/logical-components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/curityio/oauth-agent-node-nextjs/HEAD/doc/logical-components.png -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bullseye 2 | 3 | WORKDIR /usr/oauth-agent 4 | COPY src /usr/oauth-agent/src 5 | COPY pages /usr/oauth-agent/pages 6 | COPY next-env.d.ts /usr/oauth-agent/ 7 | COPY tsconfig.json /usr/oauth-agent/ 8 | COPY package*.json /usr/oauth-agent/ 9 | 10 | RUN npm install 11 | RUN npm run build 12 | 13 | RUN groupadd --gid 10000 apiuser \ 14 | && useradd --uid 10001 --gid apiuser --shell /bin/bash --create-home apiuser 15 | USER 10001 16 | 17 | CMD ["npm", "start"] 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "incremental": true, 15 | "module": "commonjs", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "jsx": "preserve", 19 | "esModuleInterop": true, 20 | "isolatedModules": true 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "src/**/*.ts" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /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/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'; 18 | 19 | export interface ClientOptions { 20 | extraParams: ExtraParam[]; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/redirectUri.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 | function getRedirectUri(baseUrl: string, path: string, scheme: string): string { 18 | return scheme + '://' + baseUrl + path 19 | } 20 | 21 | export default getRedirectUri 22 | -------------------------------------------------------------------------------- /src/OauthAgentResponse.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 {NextApiResponse} from "next"; 18 | import {RequestLog} from "./middleware/requestLog"; 19 | 20 | export type OauthAgentResponse = NextApiResponse & { 21 | logger: RequestLog 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/exceptions/OAuthAgentException.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 default abstract class OAuthAgentException extends Error { 18 | public statusCode = 500 19 | public code = 'server_error' 20 | public logInfo: string = '' 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidStateException.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' 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 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' 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/MethodNotAllowedException.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' 18 | 19 | export default class MethodNotAllowedException extends OAuthAgentException { 20 | public statusCode = 405 21 | public code = 'method_not_allowed' 22 | public cause?: Error 23 | 24 | constructor(cause?: Error) { 25 | super('Method not allowed') 26 | this.cause = cause 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidIDTokenException.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' 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/UnhandledException.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' 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 | } 29 | -------------------------------------------------------------------------------- /src/lib/exceptions/InvalidCookieException.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' 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/UnauthorizedException.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' 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/CookieDecryptionException.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' 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/middleware/handleCathingWithLoggingAndCors.ts: -------------------------------------------------------------------------------- 1 | import {config} from "../config"; 2 | import Cors from "cors"; 3 | import {NextApiRequest} from "next"; 4 | import {OauthAgentResponse} from "../OauthAgentResponse"; 5 | import runMiddleware from "./runMiddleware"; 6 | import loggingMiddleware from "./loggingMiddleware"; 7 | import handleException from "./handleException"; 8 | 9 | const corsConfiguration = { 10 | origin: config.trustedWebOrigins, 11 | credentials: true, 12 | methods: ['POST'] 13 | } 14 | 15 | const cors = Cors(corsConfiguration) 16 | 17 | export default function handleCatchingWithLoggingAndCors(handler: (req: NextApiRequest, res: OauthAgentResponse) => unknown | Promise) { 18 | return async (req: NextApiRequest, res: OauthAgentResponse) => { 19 | await runMiddleware(req, res, loggingMiddleware) 20 | 21 | if (config.corsEnabled) { 22 | await runMiddleware(req, res, cors) 23 | } 24 | 25 | try { 26 | await handler(req, res) 27 | } catch(e) { 28 | handleException(e, req, res) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/exceptions/AuthorizationServerException.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' 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/getLogoutURL.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 OAuthAgentConfiguration from './oauthAgentConfiguration' 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/middleware/runMiddleware.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 {NextApiRequest} from "next"; 18 | import {OauthAgentResponse} from "../OauthAgentResponse"; 19 | 20 | export default function runMiddleware(req: NextApiRequest, res: OauthAgentResponse, fn: any) { 21 | return new Promise((resolve, reject) => { 22 | fn(req, res, (result: any) => { 23 | if (result instanceof Error) { 24 | return reject(result) 25 | } 26 | 27 | return resolve(result) 28 | }) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/middleware/loggingMiddleware.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 {RequestLog} from './requestLog' 18 | import {OauthAgentResponse} from "../OauthAgentResponse"; 19 | import {NextApiRequest} from "next"; 20 | 21 | export default function loggingMiddleware( 22 | request: NextApiRequest, 23 | response: OauthAgentResponse, 24 | next: any) { 25 | response.logger = new RequestLog() 26 | response.logger.start(request) 27 | 28 | response.on('finish', () => { 29 | response.logger.end(response) 30 | }) 31 | 32 | next() 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/cookieName.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 | 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oauth-agent-node-nextjs", 3 | "version": "1.0.0", 4 | "description": "An OAuth Agent that runs on the Next.js web server", 5 | "author": "Curity AB", 6 | "license": "Apache-2.0", 7 | "scripts": { 8 | "dev": "next dev", 9 | "build": "next build", 10 | "start": "next start", 11 | "wiremock": "wiremock --root-dir test/integration --port 8443", 12 | "test": "mocha -r ts-node/register test/integration/**.ts" 13 | }, 14 | "dependencies": { 15 | "base64url": "^3.0.1", 16 | "cookie": "^0.5.0", 17 | "cors": "^2.8.5", 18 | "jose": "^4.14.4", 19 | "next": "^12.2.0", 20 | "node-fetch": "^2.6.7", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "swr": "^1.3.0", 24 | "url-parse": "^1.5.10" 25 | }, 26 | "devDependencies": { 27 | "@types/chai": "^4.3.1", 28 | "@types/cookie": "^0.5.1", 29 | "@types/cors": "^2.8.12", 30 | "@types/mocha": "^9.1.1", 31 | "@types/node": "^18.0.0", 32 | "@types/node-fetch": "^2.6.2", 33 | "@types/react": "^18.0.14", 34 | "@types/set-cookie-parser": "^2.4.2", 35 | "@types/url-parse": "^1.4.8", 36 | "chai": "^4.3.6", 37 | "mocha": "^10.0.0", 38 | "set-cookie-parser": "^2.5.0", 39 | "ts-node": "^10.9.1", 40 | "typescript": "^5.1.5", 41 | "wiremock": "^2.35.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /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](https://nodejs.org/en/download/) 10 | - [jq](https://stedolan.github.io/jq/download/) 11 | 12 | ## Build and Run the OAuth Agent 13 | 14 | Run these commands from the root folder and the API will then listen on HTTP over port 3000: 15 | 16 | ```bash 17 | npm install 18 | npm run dev 19 | ``` 20 | 21 | Test that the API is contactable by running this command from the root folder: 22 | 23 | ```bash 24 | curl -X POST http://localhost:3000/api/login/start \ 25 | -H "origin: http://www.example.local" | jq 26 | ``` 27 | 28 | ## Run Integration Tests 29 | 30 | Run some tests that require only a running OAuth Agent, with a mocked Identity Server: 31 | 32 | ```bash 33 | npm run wiremock 34 | npm test 35 | ``` 36 | 37 | ## Running Docker 38 | 39 | There is a Docker file that you can use to build and run the OAuth Agent without having to install any tools on your local machine — you only need Docker Desktop. 40 | 41 | First, run this command from the root directory to build the Docker image: 42 | 43 | ```shell 44 | docker build -t oauth-agent . 45 | ``` 46 | 47 | Then run the image with: 48 | 49 | ```shell 50 | docker run -p 3000:3000 --rm oauth-agent 51 | ``` 52 | -------------------------------------------------------------------------------- /src/lib/idTokenValidator.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | import {InvalidIDTokenException} from './exceptions'; 3 | import OAuthAgentConfiguration from './oauthAgentConfiguration'; 4 | 5 | /* 6 | * Make some sanity checks to ensure that the issuer and audience are configured correctly 7 | * The ID token is received over a trusted back channel connection so its signature does not need verifying 8 | * https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation 9 | */ 10 | export function validateIDtoken(config: OAuthAgentConfiguration, idToken: string) { 11 | 12 | // For backwards compatibility, only validate the issuer when one is configured 13 | if (process.env.ISSUER) { 14 | 15 | const payload = jose.decodeJwt(idToken) 16 | 17 | if (payload.iss !== config.issuer) { 18 | throw new InvalidIDTokenException(new Error('Unexpected iss claim')) 19 | } 20 | 21 | const audience = getAudienceClaim(payload.aud) 22 | if (audience.indexOf(config.clientID) === -1) { 23 | throw new InvalidIDTokenException(new Error('Unexpected aud claim')) 24 | } 25 | } 26 | } 27 | 28 | function getAudienceClaim(aud: any): string[] { 29 | 30 | if (typeof aud === 'string') { 31 | return [aud] 32 | } 33 | 34 | if (Array.isArray(aud)) { 35 | return aud 36 | } 37 | 38 | return [] 39 | } 40 | -------------------------------------------------------------------------------- /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' 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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/validateNextRequest.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 validateRequest, {ValidateRequestData, ValidateRequestOptions} from './lib/validateRequest' 18 | import {config} from './config' 19 | import {getCSRFCookieName} from './lib' 20 | import {NextApiRequest} from "next" 21 | 22 | export default function validateNextRequest(req: NextApiRequest, options: ValidateRequestOptions) { 23 | let csrfCookie: string | undefined 24 | const csrfCookieHeader = req.headers['x-' + config.cookieNamePrefix + '-csrf'] 25 | if (Array.isArray(csrfCookieHeader)) { 26 | csrfCookie = csrfCookieHeader[0] 27 | } else { 28 | csrfCookie = csrfCookieHeader 29 | } 30 | 31 | const data = new ValidateRequestData( 32 | csrfCookie, 33 | req.cookies && req.cookies[getCSRFCookieName(config.cookieNamePrefix)], 34 | req.headers.origin, 35 | config.trustedWebOrigins, 36 | config.encKey, 37 | ) 38 | 39 | validateRequest(data, options); 40 | } 41 | -------------------------------------------------------------------------------- /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' 18 | import {InvalidCookieException, InvalidIDTokenException} from './exceptions' 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 | -------------------------------------------------------------------------------- /pages/api/login/start.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest} from 'next' 2 | import {getAuthorizationURL, getTempLoginDataCookie, ValidateRequestOptions} from "../../../src/lib"; 3 | import validateNextRequest from "../../../src/validateNextRequest"; 4 | import {config} from "../../../src/config"; 5 | import handleException from "../../../src/middleware/handleException"; 6 | import {OauthAgentResponse} from "../../../src/OauthAgentResponse"; 7 | import MethodNotAllowedException from "../../../src/lib/exceptions/MethodNotAllowedException"; 8 | import handleCatchingWithLoggingAndCors from "../../../src/middleware/handleCathingWithLoggingAndCors"; 9 | 10 | const handler = (req: NextApiRequest, res: OauthAgentResponse) => { 11 | if (req.method === 'POST') { 12 | handlePost(req, res) 13 | } else { 14 | const error = new MethodNotAllowedException() 15 | handleException(error, req, res) 16 | } 17 | } 18 | 19 | const handlePost = (req: NextApiRequest, res: OauthAgentResponse) => { 20 | 21 | // Verify the web origin 22 | const options = new ValidateRequestOptions() 23 | options.requireCsrfHeader = false; 24 | validateNextRequest(req, options) 25 | const authorizationRequestData = getAuthorizationURL(config, req.body) 26 | 27 | res.setHeader('Set-Cookie', 28 | getTempLoginDataCookie(authorizationRequestData.codeVerifier, authorizationRequestData.state, config.cookieOptions, config.cookieNamePrefix, config.encKey)) 29 | 30 | res.status(200).json({ 31 | authorizationRequestUrl: authorizationRequestData.authorizationRequestURL 32 | }) 33 | } 34 | 35 | export default handleCatchingWithLoggingAndCors(handler) 36 | -------------------------------------------------------------------------------- /pages/api/logout.ts: -------------------------------------------------------------------------------- 1 | import {NextApiRequest} from "next"; 2 | import {OauthAgentResponse} from "../../src/OauthAgentResponse"; 3 | import {getATCookieName, getCookiesForUnset, getLogoutURL, ValidateRequestOptions} from "../../src/lib"; 4 | import validateNextRequest from "../../src/validateNextRequest"; 5 | import {config} from "../../src/config"; 6 | import {InvalidCookieException} from "../../src/lib/exceptions"; 7 | import handleException from "../../src/middleware/handleException"; 8 | import MethodNotAllowedException from "../../src/lib/exceptions/MethodNotAllowedException"; 9 | import handleCatchingWithLoggingAndCors from "../../src/middleware/handleCathingWithLoggingAndCors"; 10 | 11 | const handler = (req: NextApiRequest, res: OauthAgentResponse) => { 12 | if (req.method === 'POST') { 13 | handlePost(req, res) 14 | } else { 15 | const error = new MethodNotAllowedException() 16 | handleException(error, req, res) 17 | } 18 | } 19 | 20 | const handlePost = (req: NextApiRequest, res: OauthAgentResponse) => { 21 | const options = new ValidateRequestOptions() 22 | validateNextRequest(req, options) 23 | 24 | if (req.cookies && req.cookies[getATCookieName(config.cookieNamePrefix)]) { 25 | 26 | const logoutURL = getLogoutURL(config) 27 | res.setHeader('Set-Cookie', getCookiesForUnset(config.cookieOptions, config.cookieNamePrefix)) 28 | res.json({ url: logoutURL}) 29 | 30 | } else { 31 | const error = new InvalidCookieException() 32 | error.logInfo = 'No auth cookie was supplied in a logout call' 33 | handleException(error, req, res) 34 | } 35 | } 36 | 37 | export default handleCatchingWithLoggingAndCors(handler) 38 | -------------------------------------------------------------------------------- /pages/api/claims.ts: -------------------------------------------------------------------------------- 1 | import {NextApiRequest} from "next"; 2 | import {OauthAgentResponse} from "../../src/OauthAgentResponse"; 3 | import handleException from "../../src/middleware/handleException"; 4 | import {getIDCookieName, getIDTokenClaims, ValidateRequestOptions} from "../../src/lib"; 5 | import {config} from "../../src/config"; 6 | import validateNextRequest from "../../src/validateNextRequest"; 7 | import {InvalidCookieException} from "../../src/lib/exceptions"; 8 | import MethodNotAllowedException from "../../src/lib/exceptions/MethodNotAllowedException"; 9 | import handleCatchingWithLoggingAndCors from "../../src/middleware/handleCathingWithLoggingAndCors"; 10 | 11 | const handler = (req: NextApiRequest, res: OauthAgentResponse) => { 12 | if (req.method === 'GET') { 13 | handleGet(req, res) 14 | } else { 15 | const error = new MethodNotAllowedException() 16 | handleException(error, req, res) 17 | } 18 | } 19 | 20 | const handleGet = (req: NextApiRequest, res: OauthAgentResponse) => { 21 | 22 | const options = new ValidateRequestOptions() 23 | options.requireTrustedOrigin = config.corsEnabled; 24 | options.requireCsrfHeader = false; 25 | validateNextRequest(req, options) 26 | 27 | const idTokenCookieName = getIDCookieName(config.cookieNamePrefix) 28 | if (req.cookies && req.cookies[idTokenCookieName]) { 29 | 30 | const userData = getIDTokenClaims(config.encKey, req.cookies[idTokenCookieName]) 31 | res.status(200).json(userData) 32 | 33 | } else { 34 | const error = new InvalidCookieException() 35 | error.logInfo = 'No ID cookie was supplied in a call to get claims' 36 | handleException(error, req, res) 37 | } 38 | } 39 | 40 | export default handleCatchingWithLoggingAndCors(handler) 41 | -------------------------------------------------------------------------------- /pages/api/userInfo.ts: -------------------------------------------------------------------------------- 1 | import {NextApiRequest} from "next"; 2 | import {OauthAgentResponse} from "../../src/OauthAgentResponse"; 3 | import handleException from "../../src/middleware/handleException"; 4 | import {getATCookieName, getUserInfo, ValidateRequestOptions} from "../../src/lib"; 5 | import validateNextRequest from "../../src/validateNextRequest"; 6 | import {config} from "../../src/config"; 7 | import {InvalidCookieException} from "../../src/lib/exceptions"; 8 | import MethodNotAllowedException from "../../src/lib/exceptions/MethodNotAllowedException"; 9 | import handleCatchingWithLoggingAndCors from "../../src/middleware/handleCathingWithLoggingAndCors"; 10 | 11 | const handler = async (req: NextApiRequest, res: OauthAgentResponse) => { 12 | if (req.method === 'GET') { 13 | await handleGet(req, res) 14 | } else { 15 | const error = new MethodNotAllowedException() 16 | handleException(error, req, res) 17 | } 18 | } 19 | 20 | const handleGet = async (req: NextApiRequest, res: OauthAgentResponse) => { 21 | 22 | const options = new ValidateRequestOptions() 23 | options.requireTrustedOrigin = config.corsEnabled; 24 | options.requireCsrfHeader = false; 25 | validateNextRequest(req, options) 26 | 27 | const atCookieName = getATCookieName(config.cookieNamePrefix) 28 | if (req.cookies && req.cookies[atCookieName]) { 29 | 30 | const userData = await getUserInfo(config, config.encKey, req.cookies[atCookieName]) 31 | res.status(200).json(userData) 32 | 33 | } else { 34 | const error = new InvalidCookieException() 35 | error.logInfo = 'No AT cookie was supplied in a call to get user info' 36 | handleException(error, req, res) 37 | } 38 | } 39 | 40 | export default handleCatchingWithLoggingAndCors(handler) 41 | -------------------------------------------------------------------------------- /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' 18 | 19 | export default class AuthorizationClientException extends OAuthAgentException { 20 | 21 | // By default the SPA will present an error when the Authorization Server returns an 22 | public statusCode = 400 23 | public code = 'authorization_error' 24 | public description = '' 25 | 26 | constructor() { 27 | super('A request sent to the Authorization Server was rejected') 28 | } 29 | 30 | // User info requests can fail, in which case inform the SPA so that it can avoid an error display 31 | public onUserInfoFailed(status: number) { 32 | 33 | if (status == 401) { 34 | 35 | this.code = 'token_expired' 36 | this.statusCode = 401 37 | } 38 | } 39 | 40 | // Token refresh will fail eventually, in which case inform the SPA so that it can avoid an error display 41 | public onTokenRefreshFailed(text: string) { 42 | 43 | if (text.indexOf('invalid_grant') !== -1) { 44 | 45 | this.code = 'session_expired' 46 | this.statusCode = 401 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/exceptions/index.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' 18 | import InvalidCookieException from './InvalidCookieException' 19 | import CookieDecryptionException from './CookieDecryptionException' 20 | import InvalidIDTokenException from './InvalidIDTokenException' 21 | import MissingTempLoginDataException from './MissingCodeVerifierException' 22 | import InvalidStateException from './InvalidStateException' 23 | import UnauthorizedException from './UnauthorizedException' 24 | import AuthorizationClientException from './AuthorizationClientException' 25 | import AuthorizationResponseException from './AuthorizationResponseException' 26 | import AuthorizationServerException from './AuthorizationServerException' 27 | import UnhandledException from './UnhandledException' 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/middleware/handleException.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, UnhandledException} from '../lib/exceptions' 18 | import {config} from '../config' 19 | import {getCookiesForUnset } from'../lib'; 20 | import {RequestLog} from './requestLog'; 21 | import {NextApiRequest} from "next"; 22 | import {OauthAgentResponse} from "../OauthAgentResponse"; 23 | 24 | export default function handleException( 25 | caught: any, 26 | request: NextApiRequest, 27 | response: OauthAgentResponse): void { 28 | 29 | const exception = caught instanceof OAuthAgentException ? caught : new UnhandledException(caught) 30 | 31 | if (!response.logger) { 32 | 33 | // For malformed JSON errors, middleware does not get created so write the whole log here 34 | response.logger = new RequestLog() 35 | response.logger.start(request) 36 | response.logger.addError(exception) 37 | response.logger.end(response) 38 | 39 | } else { 40 | 41 | // Otherwise just include error details in logs 42 | response.logger.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 | -------------------------------------------------------------------------------- /pages/api/refresh.ts: -------------------------------------------------------------------------------- 1 | import {NextApiRequest} from "next"; 2 | import {OauthAgentResponse} from "../../src/OauthAgentResponse"; 3 | import handleException from "../../src/middleware/handleException"; 4 | import { 5 | decryptCookie, 6 | getAuthCookieName, 7 | getCookiesForTokenResponse, 8 | refreshAccessToken, 9 | validateIDtoken, 10 | ValidateRequestOptions 11 | } from "../../src/lib"; 12 | import validateNextRequest from "../../src/validateNextRequest"; 13 | import {config} from "../../src/config"; 14 | import {InvalidCookieException} from "../../src/lib/exceptions"; 15 | import MethodNotAllowedException from "../../src/lib/exceptions/MethodNotAllowedException"; 16 | import handleCatchingWithLoggingAndCors from "../../src/middleware/handleCathingWithLoggingAndCors"; 17 | 18 | const handler = async (req: NextApiRequest, res: OauthAgentResponse) => { 19 | if (req.method === 'POST') { 20 | await handlePost(req, res) 21 | } else { 22 | const error = new MethodNotAllowedException() 23 | handleException(error, req, res) 24 | } 25 | } 26 | 27 | const handlePost = async (req: NextApiRequest, res: OauthAgentResponse) => { 28 | // Check for an allowed origin and the presence of a CSRF token 29 | const options = new ValidateRequestOptions() 30 | validateNextRequest(req, options) 31 | 32 | const authCookieName = getAuthCookieName(config.cookieNamePrefix) 33 | if (req.cookies && req.cookies[authCookieName]) { 34 | 35 | const refreshToken = decryptCookie(config.encKey, req.cookies[authCookieName]) 36 | const tokenResponse = await refreshAccessToken(refreshToken, config) 37 | if (tokenResponse.id_token) { 38 | validateIDtoken(config, tokenResponse.id_token) 39 | } 40 | 41 | const cookiesToSet = getCookiesForTokenResponse(tokenResponse, config) 42 | res.setHeader('Set-Cookie', cookiesToSet) 43 | res.status(204).end() 44 | 45 | } else { 46 | const error = new InvalidCookieException() 47 | error.logInfo = 'No auth cookie was supplied in a token refresh call' 48 | handleException(error, req, res) 49 | } 50 | } 51 | 52 | export default handleCatchingWithLoggingAndCors(handler) 53 | -------------------------------------------------------------------------------- /src/lib/index.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 { getAuthorizationURL } from './authorizationURL' 18 | import { getEncryptedCookie, decryptCookie } from './cookieEncrypter' 19 | import { getCookiesForTokenResponse, getCookiesForFailedLoginResponse, getCookiesForUnset } from './cookieBuilder' 20 | import { getTokenEndpointResponse, refreshAccessToken } from './getToken' 21 | import { validateIDtoken } from './idTokenValidator' 22 | import getUserInfo from './getUserInfo' 23 | import getIDTokenClaims from './getIDTokenClaims' 24 | import getRedirectUri from './redirectUri' 25 | import getLogoutURL from './getLogoutURL' 26 | import OAuthAgentConfiguration from './oauthAgentConfiguration' 27 | import { getTempLoginDataCookie, getTempLoginDataCookieForUnset, generateRandomString } from './pkce' 28 | import { getAuthCookieName, getIDCookieName, getCSRFCookieName, getATCookieName, getTempLoginDataCookieName } from './cookieName' 29 | import { ClientOptions } from './clientOptions' 30 | import { ValidateRequestOptions } from './validateRequest' 31 | 32 | export { 33 | getAuthorizationURL, 34 | getEncryptedCookie, 35 | decryptCookie, 36 | getTokenEndpointResponse, 37 | getUserInfo, 38 | getIDTokenClaims, 39 | getRedirectUri, 40 | getLogoutURL, 41 | refreshAccessToken, 42 | getCookiesForUnset, 43 | getTempLoginDataCookieForUnset, 44 | getTempLoginDataCookie, 45 | getCookiesForFailedLoginResponse, 46 | getCookiesForTokenResponse, 47 | getATCookieName, 48 | getTempLoginDataCookieName, 49 | getCSRFCookieName, 50 | getIDCookieName, 51 | getAuthCookieName, 52 | generateRandomString, 53 | validateIDtoken, 54 | OAuthAgentConfiguration, 55 | ValidateRequestOptions 56 | } 57 | 58 | export type { ClientOptions } 59 | 60 | -------------------------------------------------------------------------------- /src/lib/authorizationURL.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 {ClientOptions} from './clientOptions' 18 | import OAuthAgentConfiguration from './oauthAgentConfiguration' 19 | import {generateHash, generateRandomString} from './pkce' 20 | 21 | function getAuthorizationURL(config: OAuthAgentConfiguration, options?: ClientOptions): AuthorizationRequestData { 22 | const codeVerifier = generateRandomString() 23 | const state = generateRandomString() 24 | 25 | let authorizationRequestUrl = config.authorizeEndpoint + "?" + 26 | "client_id=" + encodeURIComponent(config.clientID) + 27 | "&state=" + encodeURIComponent(state) + 28 | "&response_type=code" + 29 | "&redirect_uri=" + encodeURIComponent(config.redirectUri) + 30 | "&code_challenge=" + generateHash(codeVerifier) + 31 | "&code_challenge_method=S256" 32 | 33 | if (options && options.extraParams) { 34 | options.extraParams.forEach((p) => { 35 | if (p.key && p.value) { 36 | authorizationRequestUrl += `&${p.key}=${encodeURIComponent(p.value)}` 37 | } 38 | }); 39 | } 40 | 41 | if (config.scope) { 42 | authorizationRequestUrl += "&scope=" + encodeURIComponent(config.scope) 43 | } 44 | 45 | return new AuthorizationRequestData(authorizationRequestUrl, codeVerifier, state) 46 | } 47 | 48 | class AuthorizationRequestData { 49 | public readonly authorizationRequestURL: string 50 | public readonly codeVerifier: string 51 | public readonly state: string 52 | 53 | constructor(authorizationRequestURL: string, codeVerifier: string, state: string) { 54 | this.authorizationRequestURL = authorizationRequestURL 55 | this.codeVerifier = codeVerifier 56 | this.state = state 57 | } 58 | } 59 | 60 | export { getAuthorizationURL } 61 | -------------------------------------------------------------------------------- /src/lib/pkce.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 * as crypto from 'crypto' 18 | import {CookieSerializeOptions, serialize} from 'cookie' 19 | import {getTempLoginDataCookieName} from './cookieName' 20 | import {encryptCookie} from './cookieEncrypter' 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: CookieSerializeOptions, cookieNamePrefix: string, encKey: string): string { 48 | return serialize(getTempLoginDataCookieName(cookieNamePrefix), encryptCookie(encKey, JSON.stringify({ codeVerifier, state })), options) 49 | } 50 | 51 | function getTempLoginDataCookieForUnset(options: CookieSerializeOptions, 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/config.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 {CookieSerializeOptions} from 'cookie' 18 | import {OAuthAgentConfiguration} from "./lib"; 19 | 20 | export const config: OAuthAgentConfiguration = { 21 | clientID: process.env.CLIENT_ID || 'spa-client', 22 | clientSecret: process.env.CLIENT_SECRET || 'Password1', 23 | redirectUri: process.env.REDIRECT_URI || 'http://www.example.local/', 24 | postLogoutRedirectURI: process.env.POST_LOGOUT_REDIRECT_URI || 'http://www.example.local/', 25 | scope: process.env.SCOPE || 'openid profile', 26 | 27 | encKey: process.env.COOKIE_ENCRYPTION_KEY || '4e4636356d65563e4c73233847503e3b21436e6f7629724950526f4b5e2e4e50', 28 | cookieNamePrefix: process.env.COOKIE_NAME_PREFIX || 'example', 29 | endpointsPrefix: '/oauth-agent', 30 | trustedWebOrigins: [process.env.TRUSTED_WEB_ORIGIN || 'http://www.example.local'], 31 | corsEnabled: process.env.CORS_ENABLED ? process.env.CORS_ENABLED === 'true' : true, 32 | cookieOptions: { 33 | httpOnly: true, 34 | sameSite: true, 35 | secure: false, 36 | domain: process.env.COOKIE_DOMAIN || 'api.example.local', 37 | path: process.env.COOKIE_BASE_PATH || '/', 38 | } as CookieSerializeOptions, 39 | 40 | issuer: process.env.ISSUER || 'http://login.example.local:8443/oauth/v2/oauth-anonymous', 41 | authorizeEndpoint: process.env.AUTHORIZE_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-authorize', 42 | logoutEndpoint: process.env.LOGOUT_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-session/logout', 43 | tokenEndpoint: process.env.TOKEN_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-token', 44 | userInfoEndpoint: process.env.USERINFO_ENDPOINT || 'http://login.example.local:8443/oauth/v2/oauth-userinfo', 45 | port: process.env.PORT || '3000', 46 | serverCertPassword: '', 47 | serverCertPath: '' 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/getUserInfo.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 fetch from 'node-fetch' 18 | import {decryptCookie} from './cookieEncrypter' 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration' 20 | import {OAuthAgentException, InvalidCookieException, AuthorizationClientException, AuthorizationServerException} from './exceptions' 21 | 22 | async function getUserInfo(config: OAuthAgentConfiguration, encKey: string, encryptedCookie: string): Promise { 23 | 24 | try { 25 | let accessToken = null 26 | try { 27 | accessToken = decryptCookie(encKey, encryptedCookie) 28 | } catch (err: any) { 29 | const error = new InvalidCookieException(err) 30 | error.logInfo = 'Unable to decrypt the access token cookie to get user info' 31 | throw error 32 | } 33 | 34 | const res = await fetch( 35 | config.userInfoEndpoint, 36 | { 37 | method: 'POST', 38 | headers: { 39 | 'Authorization': 'Bearer ' + accessToken, 40 | 'Content-Type': 'application/x-www-form-urlencoded' 41 | }, 42 | }) 43 | 44 | // Read text if it exists 45 | const text = await res.text() 46 | 47 | if (res.status >= 500) { 48 | const error = new AuthorizationServerException() 49 | error.logInfo = `Server error response in a User Info request: ${text}` 50 | throw error 51 | } 52 | 53 | if (res.status >= 400) { 54 | const error = new AuthorizationClientException() 55 | error.onUserInfoFailed(res.status) 56 | error.logInfo = `User Info request was rejected: ${text}` 57 | throw error 58 | } 59 | 60 | return JSON.parse(text) 61 | 62 | } catch (err: any) { 63 | 64 | if (!(err instanceof OAuthAgentException)) { 65 | const error = new AuthorizationServerException(err) 66 | error.logInfo = 'Connectivity problem during a User Info request' 67 | throw error 68 | } else { 69 | throw err 70 | } 71 | } 72 | } 73 | 74 | export default getUserInfo 75 | -------------------------------------------------------------------------------- /src/lib/validateRequest.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 {UnauthorizedException} from './exceptions' 18 | import {decryptCookie} from './cookieEncrypter' 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 | -------------------------------------------------------------------------------- /test/integration/claimsControllerTests.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 {assert, expect} from 'chai'; 18 | import fetch from 'node-fetch'; 19 | import {config} from '../../src/config'; 20 | import {performLogin} from './testUtils' 21 | 22 | // Tests to focus on returning ID token details 23 | describe('ClaimsControllerTests', () => { 24 | 25 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 26 | 27 | it('Requesting claims from an untrusted origin should return a 401 response', async () => { 28 | 29 | const response = await fetch( 30 | `${oauthAgentBaseUrl}/claims`, 31 | { 32 | method: 'GET', 33 | headers: { 34 | origin: 'https://malicious-site.com', 35 | }, 36 | }, 37 | ) 38 | 39 | assert.equal(response.status, 401, 'Incorrect HTTP status') 40 | const body = await response.json() as any 41 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 42 | }) 43 | 44 | it('Requesting claims without session cookies should return a 401 response', async () => { 45 | 46 | const response = await fetch( 47 | `${oauthAgentBaseUrl}/claims`, 48 | { 49 | method: 'GET', 50 | headers: { 51 | origin: config.trustedWebOrigins[0], 52 | }, 53 | }, 54 | ) 55 | 56 | assert.equal(response.status, 401, 'Incorrect HTTP status') 57 | const body = await response.json() as any 58 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 59 | }) 60 | 61 | it('Requesting claims with valid cookies should return ID Token claims', async () => { 62 | 63 | const [, , cookieString] = await performLogin() 64 | const response = await fetch( 65 | `${oauthAgentBaseUrl}/claims`, 66 | { 67 | method: 'GET', 68 | headers: { 69 | origin: config.trustedWebOrigins[0], 70 | cookie: cookieString, 71 | }, 72 | }, 73 | ) 74 | 75 | assert.equal(response.status, 200, 'Incorrect HTTP status') 76 | const body = await response.json() as any 77 | expect(body.auth_time.toString(), 'Missing auth_time claim').length.above(0) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/middleware/requestLog.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 '../lib/exceptions'; 18 | import {NextApiRequest, NextApiResponse} from "next"; 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: NextApiRequest) { 29 | 30 | this.time = new Date().toUTCString() 31 | this.method = request.method, 32 | this.path = request.url 33 | } 34 | 35 | public addError(error: OAuthAgentException) { 36 | this.error = error 37 | } 38 | 39 | public end(response: NextApiResponse) { 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/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 {CookieSerializeOptions, serialize} from 'cookie' 18 | import {getEncryptedCookie} from './cookieEncrypter' 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration' 20 | import {getATCookieName, getAuthCookieName, getCSRFCookieName, getIDCookieName} from './cookieName' 21 | import {getTempLoginDataCookieForUnset} from './pkce' 22 | 23 | function getCookiesForFailedLoginResponse(config: OAuthAgentConfiguration): string[] { 24 | 25 | return [ 26 | getTempLoginDataCookieForUnset(config.cookieOptions, config.cookieNamePrefix) 27 | ] 28 | } 29 | 30 | function getCookiesForTokenResponse(tokenResponse: any, config: OAuthAgentConfiguration, unsetTempLoginDataCookie: boolean = false, csrfCookieValue?: string): string[] { 31 | 32 | const cookies = [ 33 | getEncryptedCookie(config.cookieOptions, tokenResponse.access_token, getATCookieName(config.cookieNamePrefix), config.encKey) 34 | ] 35 | 36 | if (csrfCookieValue) { 37 | cookies.push(getEncryptedCookie(config.cookieOptions, csrfCookieValue, getCSRFCookieName(config.cookieNamePrefix), config.encKey)) 38 | } 39 | 40 | if (unsetTempLoginDataCookie) { 41 | cookies.push(getTempLoginDataCookieForUnset(config.cookieOptions, config.cookieNamePrefix)) 42 | } 43 | 44 | if (tokenResponse.refresh_token) { 45 | const refreshTokenCookieOptions = { 46 | ...config.cookieOptions, 47 | path: config.endpointsPrefix + '/refresh' 48 | } 49 | cookies.push(getEncryptedCookie(refreshTokenCookieOptions, tokenResponse.refresh_token, getAuthCookieName(config.cookieNamePrefix), config.encKey)) 50 | } 51 | 52 | if (tokenResponse.id_token) { 53 | const idTokenCookieOptions = { 54 | ...config.cookieOptions, 55 | path: config.endpointsPrefix + '/claims' 56 | } 57 | cookies.push(getEncryptedCookie(idTokenCookieOptions, tokenResponse.id_token, getIDCookieName(config.cookieNamePrefix), config.encKey)) 58 | } 59 | 60 | return cookies 61 | } 62 | 63 | function getCookiesForUnset(options: CookieSerializeOptions, cookieNamePrefix: string): string[] { 64 | 65 | const DAY_MILLISECONDS = 1000 * 60 * 60 * 24 66 | const cookieOptions = { 67 | ...options, 68 | expires: new Date(Date.now() - DAY_MILLISECONDS), 69 | } 70 | 71 | return [ 72 | serialize(getAuthCookieName(cookieNamePrefix), "", cookieOptions), 73 | serialize(getATCookieName(cookieNamePrefix), "", cookieOptions), 74 | serialize(getIDCookieName(cookieNamePrefix), "", cookieOptions), 75 | serialize(getCSRFCookieName(cookieNamePrefix), "", cookieOptions) 76 | ] 77 | } 78 | 79 | export { getCookiesForFailedLoginResponse, getCookiesForTokenResponse, getCookiesForUnset }; 80 | -------------------------------------------------------------------------------- /src/lib/oauthAgentConfiguration.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 {CookieSerializeOptions} 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: CookieSerializeOptions 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?: CookieSerializeOptions) { 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 CookieSerializeOptions 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/cookieEncrypter.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 * as crypto from 'crypto' 18 | import base64url from 'base64url'; 19 | import {CookieSerializeOptions, serialize} from 'cookie' 20 | import {CookieDecryptionException, InvalidCookieException} from '../lib/exceptions' 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: CookieSerializeOptions, value: string, name: string, encKey: string): string { 90 | return serialize(name, encryptCookie(encKey, value), options) 91 | } 92 | 93 | export { getEncryptedCookie, decryptCookie, encryptCookie }; 94 | -------------------------------------------------------------------------------- /test/integration/extensibilityTests.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 {assert, expect} from 'chai' 18 | import fetch from 'node-fetch' 19 | import {config} from '../../src/config' 20 | 21 | // Tests to focus on extra details the SPA may need to supply at runtime 22 | describe('ExtensibilityTests', () => { 23 | 24 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 25 | 26 | it('Starting a login request with a simple OpenID Connect parameter should include it in the request URL', async () => { 27 | 28 | const options = { 29 | extraParams: [ 30 | { 31 | key: 'prompt', 32 | value: 'login', 33 | }, 34 | ], 35 | } 36 | 37 | const response = await fetch( 38 | `${oauthAgentBaseUrl}/login/start`, 39 | { 40 | method: 'POST', 41 | headers: { 42 | origin: config.trustedWebOrigins[0], 43 | 'content-type': 'application/json', 44 | }, 45 | body: JSON.stringify(options), 46 | }, 47 | ) 48 | 49 | assert.equal(response.status, 200, 'Incorrect HTTP status') 50 | const body = await response.json() 51 | const authorizationRequestUrl = body.authorizationRequestUrl as string 52 | 53 | expect(authorizationRequestUrl).contains( 54 | `${options.extraParams[0].key}=${options.extraParams[0].value}`, 55 | 'The extra parameter was not added to the authorization request URL') 56 | }) 57 | 58 | it('Starting a login request with multiple OpenID Connect parameters should include them in the request URL', async () => { 59 | 60 | const claims = { 61 | id_token: { 62 | acr: { 63 | essential: true, 64 | values: [ 65 | "urn:se:curity:authentication:html-form:htmlform1" 66 | ] 67 | } 68 | } 69 | } 70 | const claimsText = JSON.stringify(claims) 71 | 72 | const options = { 73 | extraParams: [ 74 | { 75 | key: 'ui_locales', 76 | value: 'fr', 77 | }, 78 | { 79 | key: 'claims', 80 | value: claimsText, 81 | }, 82 | ], 83 | } 84 | 85 | const response = await fetch( 86 | `${oauthAgentBaseUrl}/login/start`, 87 | { 88 | method: 'POST', 89 | headers: { 90 | origin: config.trustedWebOrigins[0], 91 | 'content-type': 'application/json', 92 | }, 93 | body: JSON.stringify(options), 94 | }, 95 | ) 96 | 97 | assert.equal(response.status, 200, 'Incorrect HTTP status') 98 | const body = await response.json() 99 | const authorizationRequestUrl = body.authorizationRequestUrl as string 100 | 101 | options.extraParams.forEach((p: any) => { 102 | expect(authorizationRequestUrl).contains( 103 | `${p.key}=${encodeURIComponent(p.value)}`, 104 | 'The extra parameters were not added to the authorization request URL') 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /test/integration/logoutControllerTests.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 {assert, expect} from 'chai' 18 | import fetch, {RequestInit} from 'node-fetch' 19 | import {config} from '../../src/config' 20 | import {performLogin} from './testUtils' 21 | 22 | // Tests to focus on the logout endpoint 23 | describe('LogoutControllerTests', () => { 24 | 25 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 26 | 27 | it('Posting to logout from a malicious origin should return a 401 response', async () => { 28 | 29 | const response = await fetch( 30 | `${oauthAgentBaseUrl}/logout`, 31 | { 32 | method: 'POST', 33 | headers: { 34 | origin: 'https://malicious-site.com', 35 | }, 36 | }, 37 | ) 38 | 39 | assert.equal(response.status, 401, 'Incorrect HTTP status') 40 | const body = await response.json() 41 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 42 | }) 43 | 44 | it('Posting to logout without cookies should return a 401 response', async () => { 45 | 46 | const response = await fetch( 47 | `${oauthAgentBaseUrl}/logout`, 48 | { 49 | method: 'POST', 50 | headers: { 51 | origin: config.trustedWebOrigins[0], 52 | }, 53 | }, 54 | ) 55 | 56 | assert.equal(response.status, 401, 'Incorrect HTTP status') 57 | const body = await response.json() 58 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 59 | }) 60 | 61 | it('Posting incorrect CSRF token to logout should return a 401 response', async () => { 62 | 63 | const [, , cookieString] = await performLogin() 64 | 65 | const options = { 66 | method: 'POST', 67 | headers: { 68 | origin: config.trustedWebOrigins[0], 69 | 'Content-Type': 'application/json', 70 | cookie: cookieString, 71 | }, 72 | } as RequestInit 73 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = 'abc123' 74 | 75 | const response = await fetch(`${oauthAgentBaseUrl}/logout`, options) 76 | 77 | assert.equal(response.status, 401, 'Incorrect HTTP status') 78 | const body = await response.json() 79 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 80 | }) 81 | 82 | it("Posting to logout with correct session cookies should return a 200 response and clear cookies", async () => { 83 | 84 | const [, loginBody, cookieString] = await performLogin() 85 | const options = { 86 | method: 'POST', 87 | headers: { 88 | origin: config.trustedWebOrigins[0], 89 | 'Content-Type': 'application/json', 90 | cookie: cookieString, 91 | }, 92 | } as RequestInit 93 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = loginBody['csrf'] 94 | 95 | const response = await fetch(`${oauthAgentBaseUrl}/logout`, options) 96 | 97 | assert.equal(response.status, 200, 'Incorrect HTTP status') 98 | const body = await response.json() 99 | const endSessionRequestUrl = body.url as string 100 | expect(endSessionRequestUrl).contains(`client_id=${config.clientID}`, 'Invalid end session request URL') 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/integration/userInfoControllerTests.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 {assert} from 'chai'; 18 | import fetch, {RequestInit} from 'node-fetch'; 19 | import {config} from '../../src/config'; 20 | import {fetchStubbedResponse, performLogin} from './testUtils' 21 | 22 | // Tests to focus on returning user information to the SPA via the user info endpoint 23 | describe('UserInfoControllerTests', () => { 24 | 25 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 26 | 27 | it('Requesting user info from an untrusted origin should return a 401 response', async () => { 28 | 29 | const response = await fetch( 30 | `${oauthAgentBaseUrl}/userInfo`, 31 | { 32 | method: 'GET', 33 | headers: { 34 | origin: 'https://malicious-site.com', 35 | }, 36 | }, 37 | ) 38 | 39 | assert.equal(response.status, 401, 'Incorrect HTTP status') 40 | const body = await response.json() 41 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 42 | }) 43 | 44 | it('Requesting user info without session cookies should return a 401 response', async () => { 45 | 46 | const response = await fetch( 47 | `${oauthAgentBaseUrl}/userInfo`, 48 | { 49 | method: 'GET', 50 | headers: { 51 | origin: config.trustedWebOrigins[0], 52 | }, 53 | }, 54 | ) 55 | 56 | assert.equal(response.status, 401, 'Incorrect HTTP status') 57 | const body = await response.json() 58 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 59 | }) 60 | 61 | it('Requesting user info with valid cookies should return user data', async () => { 62 | 63 | const [, , cookieString] = await performLogin() 64 | const response = await fetch( 65 | `${oauthAgentBaseUrl}/userInfo`, 66 | { 67 | method: 'GET', 68 | headers: { 69 | origin: config.trustedWebOrigins[0], 70 | cookie: cookieString, 71 | }, 72 | }, 73 | ) 74 | 75 | assert.equal(response.status, 200, 'Incorrect HTTP status') 76 | const body = await response.json() 77 | assert.equal(body.given_name, 'Demo') 78 | assert.equal(body.family_name, 'User') 79 | }) 80 | 81 | it("An expired access token when retrieving user info should return a 401 status", async () => { 82 | 83 | const [, , cookieString] = await performLogin() 84 | 85 | const options = { 86 | method: 'GET', 87 | headers: { 88 | origin: config.trustedWebOrigins[0], 89 | cookie: cookieString, 90 | }, 91 | } as RequestInit 92 | 93 | const stubbedResponse = { 94 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 95 | priority: 1, 96 | request: { 97 | method: 'POST', 98 | url: '/oauth/v2/oauth-userinfo' 99 | }, 100 | response: { 101 | 102 | // This will be returned from the Authorization Server if the access token is expired during a userinfo request 103 | status: 401, 104 | body: "{\"error\":\"invalid_token\"}" 105 | } 106 | } 107 | 108 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 109 | return await fetch(`${oauthAgentBaseUrl}/userInfo`, options) 110 | }) 111 | 112 | assert.equal(response.status, 401, 'Incorrect HTTP status') 113 | const body = await response.json() 114 | assert.equal(body.code, 'token_expired', 'Incorrect error code') 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /doc/Architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture of the OAuth Agent 2 | 3 | ## Overview 4 | 5 | Node and the [Next.js framework](https://nextjs.org) are used to build the OAuth Agent. The easiest way to deploy a Next.js app is to use the 6 | [Vercel Platform](https://vercel.com/import) from the creators of Next.js. Check out [Next.js deployment documentation](https://nextjs.org/docs/deployment) 7 | for more details. The API handles token responses from an Authorization Server, then saves encrypted tokens in http-only cookies. 8 | The API is therefore stateless and easy to manage, and does not require a database. The SPA can then use secure cookies to call 9 | business APIs, or to get userinfo from this API. 10 | 11 | ## Endpoints 12 | 13 | The API exposes the following endpoints to the SPA: 14 | 15 | 1. POST `/login/start` 16 | 2. POST `/login/end` 17 | 3. GET `/userInfo` 18 | 4. GET `/claims` 19 | 5. POST `/refresh` 20 | 6. POST `/logout` 21 | 22 | ### POST `/login/start` 23 | 24 | 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. 25 | 26 | The API responds with a JSON containing the `authorizationRequestUrl` field. 27 | 28 | #### Example request 29 | 30 | `POST https://api.example.com/api/login/start` 31 | 32 | Response: 33 | ```json 34 | { 35 | "authorizationRequestUrl": "https://idsvr.example.com/oauth/authorize?client_id=spa-client&response_type=code&scope=openid%20read&redirect_uri=https://www.example.com/" 36 | } 37 | ``` 38 | 39 | If required, the SPA can POST an object with an extra params field containing runtime OpenID Connect parameters.\ 40 | They key and value of each item must be strings. They will be appended to the request URL. 41 | 42 | ```json 43 | { 44 | "extraParams": [ 45 | { 46 | "key": "max-age", 47 | "value": "3600", 48 | }, 49 | { 50 | "key": "ui_locales", 51 | "value": "fr", 52 | }, 53 | ] 54 | } 55 | ``` 56 | 57 | ### POST `/login/end` 58 | 59 | This endpoint should 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). 60 | 61 | #### Example request 62 | 63 | ```http 64 | POST https://api.example.com/api/login/end 65 | pageUrl=http://www.example.com?code=abcdef&state=qwerty 66 | ``` 67 | 68 | The response will contain a few `Set-Cookie` headers. 69 | 70 | ### GET `/userInfo` 71 | 72 | Endpoint which sends the access token to the user info endpoint, then returns data. 73 | 74 | #### Example 75 | 76 | ```http 77 | GET https://api.example.com/api/userInfo 78 | Cookie: example-at=2558e7806c0523fd96d105... 79 | ``` 80 | 81 | Response 82 | 83 | ```json 84 | { 85 | "sub": "0abd0b16b309a3a034af8494aa0092aa42813e635f194c795df5006db90743e8", 86 | "preferred_username": "demouser", 87 | "given_name": "Demo", 88 | "updated_at": 1627313147, 89 | "family_name": "User" 90 | } 91 | ``` 92 | 93 | ### GET `/claims` 94 | 95 | Endpoint which returns claims of the ID token contained in the session cookie. 96 | 97 | #### Example 98 | 99 | ```http 100 | GET https://api.example.com/api/claims 101 | Cookie: example-id=2558e7806c0523fd96d105... 102 | ``` 103 | 104 | Response 105 | 106 | ```json 107 | { 108 | "exp":1626263589, 109 | "nbf":1626259989, 110 | "jti":"34e76304-0bc3-46ee-bc70-e21685eb5282", 111 | "iss":"https://idsvr.example.com/oauth", 112 | "aud":"spa-client", 113 | "sub":"0abd0b16b309a3a034af8494aa0092aa42813e635f194c795df5006db90743e8", 114 | "auth_time":1626259937, 115 | "iat":1626259989 116 | } 117 | ``` 118 | 119 | ### POST `/refresh` 120 | 121 | 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. 122 | 123 | ### POST `/logout` 124 | 125 | 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. 126 | -------------------------------------------------------------------------------- /src/lib/getToken.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 fetch from 'node-fetch' 18 | import {decryptCookie} from './cookieEncrypter' 19 | import OAuthAgentConfiguration from './oauthAgentConfiguration' 20 | import {OAuthAgentException, InvalidStateException, MissingTempLoginDataException, AuthorizationClientException, AuthorizationServerException} from './exceptions' 21 | 22 | async function getTokenEndpointResponse(config: OAuthAgentConfiguration, code: string, state: string, tempLoginData: string | undefined | null, ): Promise { 23 | if (!tempLoginData) { 24 | return Promise.reject(new MissingTempLoginDataException()) 25 | } 26 | 27 | const parsedTempLoginData = JSON.parse(decryptCookie(config.encKey, tempLoginData)) 28 | 29 | if (parsedTempLoginData.state !== state) { 30 | return Promise.reject(new InvalidStateException()) 31 | } 32 | 33 | try { 34 | const res = await fetch( 35 | config.tokenEndpoint, 36 | { 37 | method: 'POST', 38 | headers: { 39 | 'Authorization': 'Basic ' + Buffer.from(config.clientID+ ":" + config.clientSecret).toString('base64'), 40 | 'Content-Type': 'application/x-www-form-urlencoded' 41 | }, 42 | body: 'grant_type=authorization_code&redirect_uri=' + config.redirectUri + '&code=' + code + '&code_verifier=' + parsedTempLoginData.codeVerifier 43 | }) 44 | 45 | const text = await res.text() 46 | 47 | if (res.status >= 500) { 48 | const error = new AuthorizationServerException() 49 | error.logInfo = `Server error response in an Authorization Code Grant: ${text}` 50 | throw error 51 | } 52 | 53 | if (res.status >= 400) { 54 | const error = new AuthorizationClientException() 55 | error.logInfo = `Authorization Code Grant request was rejected: ${text}` 56 | throw error 57 | } 58 | 59 | return JSON.parse(text) 60 | 61 | } catch (err: any) { 62 | 63 | if (!(err instanceof OAuthAgentException)) { 64 | const error = new AuthorizationServerException(err) 65 | error.logInfo = 'Connectivity problem during an Authorization Code Grant' 66 | throw error 67 | } else { 68 | throw err 69 | } 70 | } 71 | } 72 | 73 | async function refreshAccessToken(refreshToken: string, config: OAuthAgentConfiguration): Promise 74 | { 75 | try { 76 | 77 | const res = await fetch( 78 | config.tokenEndpoint, 79 | { 80 | method: 'POST', 81 | headers: { 82 | 'Authorization': 'Basic ' + Buffer.from(config.clientID + ":" + config.clientSecret).toString('base64'), 83 | 'Content-Type': 'application/x-www-form-urlencoded' 84 | }, 85 | body: 'grant_type=refresh_token&refresh_token='+refreshToken 86 | }) 87 | 88 | // Read text if it exists 89 | const text = await res.text() 90 | 91 | if (res.status >= 500) { 92 | const error = new AuthorizationServerException() 93 | error.logInfo = `Server error response in a Refresh Token Grant: ${text}` 94 | throw error 95 | } 96 | 97 | if (res.status >= 400) { 98 | const error = new AuthorizationClientException() 99 | error.onTokenRefreshFailed(text) 100 | error.logInfo = `Refresh Token Grant request was rejected: ${text}` 101 | throw error 102 | } 103 | 104 | return JSON.parse(text) 105 | 106 | } catch (err: any) { 107 | 108 | if (!(err instanceof OAuthAgentException)) { 109 | 110 | const error = new AuthorizationServerException(err) 111 | error.logInfo = 'Connectivity problem during a Refresh Token Grant' 112 | throw error 113 | 114 | } else { 115 | throw err 116 | } 117 | } 118 | } 119 | 120 | export { getTokenEndpointResponse, refreshAccessToken } 121 | -------------------------------------------------------------------------------- /test/integration/testUtils.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 fetch, {RequestInit, Response} from 'node-fetch'; 18 | import { parse } from 'set-cookie-parser'; 19 | import urlParse from 'url-parse'; 20 | import {config} from '../../src/config'; 21 | import {ClientOptions} from "../../src/lib"; 22 | 23 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 24 | const wiremockAdminBaseUrl = `http://localhost:8443/__admin/mappings` 25 | 26 | /* 27 | * Do a complete login, including ending the login and getting cookies 28 | */ 29 | export async function performLogin(stateOverride: string = ''): Promise<[number, any, string]> { 30 | 31 | const [state, loginCookieString] = await startLogin() 32 | const code = '4a4246d6-b4bd-11ec-b909-0242ac120002' 33 | const payload = { 34 | pageUrl: `${oauthAgentBaseUrl}?code=${code}&state=${stateOverride || state}` 35 | } 36 | 37 | const options = { 38 | method: 'POST', 39 | headers: { 40 | origin: config.trustedWebOrigins[0], 41 | 'Content-Type': 'application/json', 42 | cookie: loginCookieString, 43 | }, 44 | body: JSON.stringify(payload), 45 | } as RequestInit 46 | 47 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 48 | const body = await response.json() 49 | 50 | const cookieString = getCookieString(response) 51 | return [response.status, body, cookieString] 52 | } 53 | 54 | /* 55 | * Get a response cookie in the form where it can be sent in subsequent requests 56 | */ 57 | export function getCookieString(response: Response) { 58 | 59 | const rawCookies = response.headers.raw()['set-cookie'] 60 | const cookies = parse(rawCookies) 61 | 62 | let allCookiesString = ''; 63 | cookies.forEach((c) => { 64 | allCookiesString += `${c.name}=${c.value};` 65 | }) 66 | 67 | return allCookiesString 68 | } 69 | 70 | /* 71 | * Do a fetch with a stubbed response, dealing with adding the stub to wiremock and then deleting it 72 | */ 73 | export async function fetchStubbedResponse(stubbedResponse: any, fetchAction: () => Promise): Promise { 74 | 75 | try { 76 | await addStub(stubbedResponse) 77 | return await fetchAction() 78 | 79 | } finally { 80 | await deleteStub(stubbedResponse.id) 81 | } 82 | } 83 | 84 | /* 85 | * Do the work to start a login and get the temp cookie 86 | */ 87 | export async function startLogin(requestBody: ClientOptions | null = null): Promise<[string, string]> { 88 | 89 | const requestOptions = { 90 | method: 'POST', 91 | headers: { 92 | origin: config.trustedWebOrigins[0], 93 | }, 94 | } as RequestInit 95 | 96 | if (requestBody) { 97 | requestOptions.body = JSON.stringify(requestBody) 98 | } 99 | 100 | const response = await fetch(`${oauthAgentBaseUrl}/login/start`, requestOptions) 101 | 102 | const body = await response.json() as any; 103 | const parsedUrl = urlParse(body.authorizationRequestUrl, true) 104 | const state = parsedUrl.query.state 105 | 106 | const cookieString = getCookieString(response) 107 | return [state!, cookieString] 108 | } 109 | 110 | /* 111 | * Add a stubbed response to Wiremock via its Admin API 112 | */ 113 | async function addStub(stubbedResponse: any): Promise { 114 | 115 | const options = { 116 | method: 'POST', 117 | headers: { 118 | 'Content-Type': 'application/json', 119 | }, 120 | body: JSON.stringify(stubbedResponse), 121 | } as RequestInit 122 | 123 | const response = await fetch(wiremockAdminBaseUrl, options) 124 | if (response.status !== 201) { 125 | const responseData = await response.text() 126 | console.log(responseData) 127 | throw new Error('Failed to add Wiremock stub') 128 | } 129 | } 130 | 131 | /* 132 | * Delete a stubbed response to Wiremock via its Admin API 133 | */ 134 | async function deleteStub(id: string): Promise { 135 | 136 | const response = await fetch(`${wiremockAdminBaseUrl}/${id}`, {method: 'DELETE'}) 137 | if (response.status !== 200) { 138 | const responseData = await response.text() 139 | console.log(responseData) 140 | throw new Error('Failed to delete Wiremock stub') 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /pages/api/login/end.ts: -------------------------------------------------------------------------------- 1 | import {NextApiRequest} from "next"; 2 | import urlparse from 'url-parse' 3 | import { 4 | decryptCookie, 5 | generateRandomString, 6 | getATCookieName, 7 | getCookiesForFailedLoginResponse, 8 | getCookiesForTokenResponse, 9 | getCSRFCookieName, 10 | getTempLoginDataCookieName, 11 | getTokenEndpointResponse, 12 | validateIDtoken, 13 | ValidateRequestOptions 14 | } from "../../../src/lib"; 15 | import validateNextRequest from "../../../src/validateNextRequest"; 16 | import {config} from "../../../src/config"; 17 | import handleException from "../../../src/middleware/handleException"; 18 | import {OauthAgentResponse} from "../../../src/OauthAgentResponse"; 19 | import {AuthorizationResponseException} from "../../../src/lib/exceptions"; 20 | import MethodNotAllowedException from "../../../src/lib/exceptions/MethodNotAllowedException"; 21 | import handleCatchingWithLoggingAndCors from "../../../src/middleware/handleCathingWithLoggingAndCors"; 22 | 23 | /* 24 | * The SPA posts its URL here on every page load, and this operation ends a login when required 25 | * The API works out whether it is an OAuth response, eg: 26 | * - code + state query parameters 27 | * - code + error query parameters 28 | * - JARM response parameters 29 | */ 30 | const handler = async (req: NextApiRequest, res: OauthAgentResponse) => { 31 | if (req.method === 'POST') { 32 | await handlePost(req, res) 33 | } else { 34 | const error = new MethodNotAllowedException() 35 | handleException(error, req, res) 36 | } 37 | } 38 | 39 | const handlePost = async (req: NextApiRequest, res: OauthAgentResponse) => { 40 | // Verify the web origin 41 | const options = new ValidateRequestOptions() 42 | options.requireCsrfHeader = false 43 | validateNextRequest(req, options) 44 | 45 | // First see if the SPA is reporting an OAuth front channel response to the browser 46 | const data = getUrlParts(req.body?.pageUrl) 47 | const isSuccessOAuthResponse = !!(data.state && data.code) 48 | const isFailedOAuthResponse = !!(data.state && data.error) 49 | 50 | // First handle reporting front channel errors back to the SPA 51 | if (isFailedOAuthResponse) { 52 | 53 | res.setHeader('Set-Cookie', getCookiesForFailedLoginResponse(config)) 54 | const error = new AuthorizationResponseException( 55 | data.error, 56 | data.error_description || 'Login failed at the Authorization Server') 57 | return handleException(error, req, res) 58 | } 59 | 60 | let isLoggedIn: boolean 61 | let csrfToken: string = '' 62 | 63 | if (isSuccessOAuthResponse) { 64 | 65 | // Main OAuth response handling 66 | const tempLoginData = req.cookies ? req.cookies[getTempLoginDataCookieName(config.cookieNamePrefix)] : undefined 67 | const tokenResponse = await getTokenEndpointResponse(config, data.code, data.state, tempLoginData) 68 | if (tokenResponse.id_token) { 69 | validateIDtoken(config, tokenResponse.id_token) 70 | } 71 | 72 | csrfToken = generateRandomString() 73 | const csrfCookie = req.cookies[getCSRFCookieName(config.cookieNamePrefix)] 74 | if (csrfCookie) { 75 | 76 | try { 77 | // Avoid setting a new value if the user opens two browser tabs and signs in on both 78 | csrfToken = decryptCookie(config.encKey, csrfCookie) 79 | 80 | } catch (e) { 81 | 82 | // If the system has been redeployed with a new cookie encryption key, decrypting old cookies from the browser will fail 83 | // In this case generate a new CSRF token so that the SPA can complete its login without errors 84 | csrfToken = generateRandomString() 85 | } 86 | } else { 87 | 88 | // By default generate a new token 89 | csrfToken = generateRandomString() 90 | } 91 | 92 | // Write the SameSite cookies 93 | const cookiesToSet = getCookiesForTokenResponse(tokenResponse, config, true, csrfToken) 94 | res.setHeader('Set-Cookie', cookiesToSet) 95 | isLoggedIn = true 96 | 97 | } else { 98 | 99 | // See if we have a session cookie 100 | isLoggedIn = !!(req.cookies && req.cookies[getATCookieName(config.cookieNamePrefix)]) 101 | if (isLoggedIn) { 102 | 103 | // During an authenticated page refresh or opening a new browser tab, we must return the anti forgery token 104 | // This enables an XSS attack to get the value, but this is standard for CSRF tokens 105 | csrfToken = decryptCookie(config.encKey, req.cookies[getCSRFCookieName(config.cookieNamePrefix)]) 106 | } 107 | } 108 | 109 | // isLoggedIn enables the SPA to know it does not need to present a login option 110 | // handled enables the SPA to know a login has just completed 111 | const responseBody = { 112 | handled: isSuccessOAuthResponse, 113 | isLoggedIn, 114 | } as any 115 | 116 | // The CSRF token is required for subsequent operations and calling APIs 117 | if (csrfToken) { 118 | responseBody.csrf = csrfToken 119 | } 120 | 121 | res.status(200).json(responseBody) 122 | } 123 | 124 | const getUrlParts = (url?: string): any => { 125 | 126 | if (url) { 127 | const urlData = urlparse(url, true) 128 | if (urlData.query) { 129 | return urlData.query 130 | } 131 | } 132 | 133 | return {} 134 | } 135 | 136 | export default handleCatchingWithLoggingAndCors(handler) 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Next.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 | - Strong browser security with `HTTP only` and `SameSite=strict` cookies 12 | - The OpenID Connect flow uses Authorization Code Flow with [PKCE](https://curity.io/resources/learn/pkce/) and a client secret 13 | 14 | ![Logical Components](/doc/logical-components.png) 15 | 16 | ## Architecture 17 | 18 | The following endpoints are used so that the SPA uses simple one-liners to perform its OAuth work: 19 | 20 | | Endpoint | Description | 21 | |-------------------|--------------------------------------------------------------------------------------| 22 | | POST /login/start | Start a login by providing the request URL to the SPA and setting temporary cookies. | 23 | | POST /login/end | Complete a login and issuing secure cookies for the SPA containing encrypted tokens. | 24 | | GET /userInfo | Return information from the User Info endpoint for the SPA to display. | 25 | | GET /claims | Return ID token claims such as `auth_time` and `acr`. | 26 | | POST /refresh | Refresh an access token and rewrite cookies. | 27 | | POST /logout | Clear cookies and return an end session request URL. | 28 | 29 | For further details see the [Architecture](/doc/Architecture.md) article. 30 | 31 | ## OAuth Agent Development 32 | 33 | See the [Setup](/doc/Setup.md) article for details on productive OAuth Agent development.\ 34 | This enables a test driven approach to developing the OAuth Agent, without the need for a browser. 35 | 36 | ## Deploying on Vercel 37 | 38 | A simple way for deploying a Next.js app is to use the https [Vercel](https://vercel.com) platform. All you need to do is to connect your repository to the Vercel dashboard and set proper environment variables. These are the variables used in the `src/config.ts` file. The app can then be deployed and is ready for use. 39 | 40 | Remember that the OAuth Agent needs to be deployed to the same parent domain as the SPA, so that cookies can be properly recognized as first-party. This means that you need to either configure your own domain in Vercel, or put the OAuth Agent behind a reverse proxy. 41 | 42 | ### Environment variables 43 | 44 | The following variables are used by the configuration of the OAuth Agent: 45 | 46 | | Variable | Description | Default | 47 | |--------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------| 48 | | CLIENT_ID | The OAuth client ID. | 'spa-client' | 49 | | CLIENT_SECRET | The client secret. | 'Password1' | 50 | | REDIRECT_URI | The URI of the SPA. | 'http://www.example.local/' | 51 | | POST_LOGOUT_REDIRECT_URI | The URI to which the user is redirected after logout. | 'http://www.example.local/' | 52 | | SCOPE | The OAuth scope parameter. | 'openid profile' | 53 | | COOKIE_ENCRYPTION_KEY | The encryption key.* | '4e4636356d65563e4c73233847503e3b21436e6f7629724950526f4b5e2e4e50' | 54 | | COOKIE_NAME_PREFIX | The prefix of cookies set by the OAuth Agent. | 'example' | 55 | | COOKIE_DOMAIN | The domain for which cookies are issued. This should be the domain of the OAuth Agent. | 'api.example.local' | 56 | | TRUSTED_WEB_ORIGIN | The origin of the SPA. Requests from other Origins will be rejected. | 'http://www.example.local' | 57 | | CORS_ENABLED | True when the agent runs in a different subdomain of the web origin, false otherwise | 'true' | 58 | | ISSUER | The issuer of the Authorization Server. | 'http://login.example.local:8443/oauth/v2/oauth-anonymous' | 59 | | AUTHORIZE_ENDPOINT | The authorization endpoint of the Authorization Server. | 'http://login.example.local:8443/oauth/v2/oauth-authorize' | 60 | | LOGOUT_ENDPOINT | The logout endpoint of the Authorization Server. | 'http://login.example.local:8443/oauth/v2/oauth-session/logout' | 61 | | TOKEN_ENDPOINT | The token endpoint of the Authorization Server. | 'http://login.example.local:8443/oauth/v2/oauth-token' | 62 | | USERINFO_ENDPOINT | The userinfo endpoint of the Authorization Server. | 'http://login.example.local:8443/oauth/v2/oauth-userinfo' | 63 | | PORT | The port where the OAuth Agent runs. This setting is only used by tests. | '3000' | 64 | 65 | * A 64-character hex string. See [this info](https://curity.io/resources/learn/token-handler-deployment-example/#cookie-encryption-keys) to learn more about the key and how it can be generated. 66 | 67 | ## End-to-End SPA Flow 68 | 69 | See the below article for details on how to run the end-to-end solution in a browser: 70 | 71 | - [SPA Code Example](https://github.com/curityio/spa-using-token-handler) 72 | 73 | The end-to-end solution, by default, uses our [Express implementation of the OAuth Agent](https://github.com/curityio/oauth-agent-node-express). 74 | If you want to run it with this Next.js implementation, then a few things would have to be changed manually in the build 75 | and deployment scripts. In this repository, you will find a `Dockerfile` that can be used with the scripts provided in the End-to-End example. 76 | 77 | ## Website Documentation 78 | 79 | See the [Curity Token Handler Design Overview](https://curity.io/resources/learn/token-handler-overview/) for further token handler information. 80 | 81 | ## More Information 82 | 83 | Please visit [curity.io](https://curity.io/) for more information about the Curity Identity Server. 84 | -------------------------------------------------------------------------------- /test/integration/refreshTokenControllerTests.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 {assert, expect} from 'chai' 18 | import fetch, {RequestInit, Response} from 'node-fetch' 19 | import {config} from '../../src/config' 20 | import {fetchStubbedResponse, getCookieString, performLogin} from './testUtils' 21 | 22 | // Tests to focus on token refresh when access tokens expire 23 | describe('RefreshTokenControllerTests', () => { 24 | 25 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 26 | 27 | it('Sending POST request to refresh endpoint from untrusted origin should return a 401 response', async () => { 28 | 29 | const response = await fetch( 30 | `${oauthAgentBaseUrl}/refresh`, 31 | { 32 | method: 'POST', 33 | headers: { 34 | origin: 'https://malicious-site.com', 35 | }, 36 | }, 37 | ) 38 | 39 | assert.equal(response.status, 401, 'Incorrect HTTP status') 40 | const body = await response.json() 41 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 42 | }) 43 | 44 | it('Sending POST request to refresh endpoint without session cookies should return a 401 response', async () => { 45 | 46 | const response = await fetch( 47 | `${oauthAgentBaseUrl}/refresh`, 48 | { 49 | method: 'POST', 50 | headers: { 51 | origin: config.trustedWebOrigins[0], 52 | }, 53 | }, 54 | ) 55 | 56 | assert.equal(response.status, 401, 'Incorrect HTTP status') 57 | const body = await response.json() 58 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 59 | }) 60 | 61 | it('Posting to refresh endpoint with incorrect CSRF token should return a 401 response', async () => { 62 | 63 | const [, , cookieString] = await performLogin() 64 | 65 | const options = { 66 | method: 'POST', 67 | headers: { 68 | origin: config.trustedWebOrigins[0], 69 | 'Content-Type': 'application/json', 70 | cookie: cookieString, 71 | }, 72 | } as RequestInit 73 | (options.headers as any)[`x-${config.cookieNamePrefix}-csrf`] = 'abc123' 74 | 75 | const response = await fetch(`${oauthAgentBaseUrl}/refresh`, options) 76 | 77 | assert.equal(response.status, 401, 'Incorrect HTTP status') 78 | const body = await response.json() 79 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 80 | }) 81 | 82 | it("Posting correct cookies to refresh endpoint should return a new set of cookies", async () => { 83 | 84 | const [, loginBody, cookieString] = await performLogin() 85 | 86 | const options = { 87 | method: 'POST', 88 | headers: { 89 | origin: config.trustedWebOrigins[0], 90 | 'Content-Type': 'application/json', 91 | cookie: cookieString, 92 | }, 93 | } as RequestInit 94 | 95 | const customHeaders = options.headers as any 96 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 97 | 98 | const response = await fetch(`${oauthAgentBaseUrl}/refresh`, options) 99 | 100 | assert.equal(response.status, 204, 'Incorrect HTTP status') 101 | const rewrittenCookieString = getCookieString(response) 102 | expect(rewrittenCookieString, 'Missing secure cookies').length.above(0) 103 | assert.notEqual(rewrittenCookieString, cookieString) 104 | }) 105 | 106 | it("A configuration error rejected by the Authorization Server when refreshing tokens should result in a 401 status code", async () => { 107 | 108 | const [, loginBody, cookieString] = await performLogin() 109 | 110 | const options = { 111 | method: 'POST', 112 | headers: { 113 | origin: config.trustedWebOrigins[0], 114 | 'Content-Type': 'application/json', 115 | cookie: cookieString, 116 | }, 117 | } as RequestInit 118 | 119 | const customHeaders = options.headers as any 120 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 121 | 122 | const stubbedResponse = { 123 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 124 | priority: 1, 125 | request: { 126 | method: 'POST', 127 | 'url': '/oauth/v2/oauth-token' 128 | }, 129 | response: { 130 | status: 400, 131 | body: "{\"error\":\"invalid_client\"}" 132 | } 133 | } 134 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 135 | return await fetch(`${oauthAgentBaseUrl}/refresh`, options) 136 | }) 137 | 138 | // The SPA cannot recover from this error so would need to present an error display 139 | assert.equal(response.status, 400, 'Incorrect HTTP status') 140 | const body = await response.json() 141 | assert.equal(body.code, 'authorization_error', 'Incorrect error code') 142 | }) 143 | 144 | it("An expired refresh token should result in a 401 response", async () => { 145 | 146 | const [, loginBody, cookieString] = await performLogin() 147 | 148 | const options = { 149 | method: 'POST', 150 | headers: { 151 | origin: config.trustedWebOrigins[0], 152 | 'Content-Type': 'application/json', 153 | cookie: cookieString, 154 | }, 155 | } as RequestInit 156 | 157 | const customHeaders = options.headers as any 158 | customHeaders[`x-${config.cookieNamePrefix}-csrf`] = loginBody.csrf 159 | 160 | const stubbedResponse = { 161 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 162 | priority: 1, 163 | request: { 164 | method: 'POST', 165 | 'url': '/oauth/v2/oauth-token' 166 | }, 167 | response: { 168 | status: 401, 169 | 170 | // In a correct setup this will be returned from the Authorization Server when the refresh token expires 171 | body: "{\"error\":\"invalid_grant\"}" 172 | } 173 | } 174 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 175 | return await fetch(`${oauthAgentBaseUrl}/refresh`, options) 176 | }) 177 | 178 | // The SPA will trigger re-authentication when it gets a 401 during token refresh 179 | assert.equal(response.status, 401, 'Incorrect HTTP status') 180 | const body = await response.json() 181 | assert.equal(body.code, 'session_expired', 'Incorrect error code') 182 | 183 | // Clear cookies so that the next call to /login/end, eg a page reload, indicates not logged in 184 | const clearedCookies = getCookieString(response); 185 | assert.equal(clearedCookies, "example-auth=;example-at=;example-id=;example-csrf=;", 'Incorrect cleared cookies string') 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /test/integration/loginControllerTests.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 fetch, {RequestInit} from 'node-fetch' 18 | import {assert, expect} from "chai"; 19 | 20 | import { config } from '../../src/config' 21 | import {fetchStubbedResponse, performLogin, startLogin} from './testUtils' 22 | 23 | // Tests to focus on the login endpoint 24 | describe('LoginControllerTests', () => { 25 | 26 | const oauthAgentBaseUrl = `http://localhost:${config.port}/api` 27 | 28 | it('Sending an OPTIONS request with wrong Origin should return 204 response without CORS headers', async () => { 29 | 30 | const response = await fetch( 31 | `${oauthAgentBaseUrl}/login/start`, 32 | { 33 | method: 'OPTIONS', 34 | headers: { 35 | origin: 'https://malicious-site.com', 36 | }, 37 | }, 38 | ) 39 | 40 | assert.equal(response.status, 204, 'Incorrect HTTP status') 41 | assert.equal(response.headers.get('access-control-allow-origin'), null, 'Incorrect allowed origin'); 42 | }) 43 | 44 | it('Sending OPTIONS request with a valid web origin should return a 204 response with proper CORS headers', async () => { 45 | 46 | const response = await fetch( 47 | `${oauthAgentBaseUrl}/login/start`, 48 | { 49 | method: 'OPTIONS', 50 | headers: { 51 | origin: config.trustedWebOrigins[0], 52 | }, 53 | }, 54 | ) 55 | 56 | assert.equal(response.status, 204, 'Incorrect HTTP status') 57 | assert.equal(response.headers.get('access-control-allow-origin'), config.trustedWebOrigins[0], 'Incorrect allowed origin'); 58 | }) 59 | 60 | it('Request to end login with invalid web origin should return 401 response', async () => { 61 | 62 | const payload = { 63 | pageUrl: 'http://www.example.com' 64 | } 65 | const response = await fetch( 66 | `${oauthAgentBaseUrl}/login/end`, 67 | { 68 | method: 'POST', 69 | headers: { 70 | origin: 'https://malicious-site.com', 71 | }, 72 | body: JSON.stringify(payload), 73 | }, 74 | ) 75 | 76 | assert.equal(response.status, 401, 'Incorrect HTTP status') 77 | const body = await response.json() as any 78 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 79 | }) 80 | 81 | it('Request to end login should return correct unauthenticated response', async () => { 82 | 83 | const payload = { 84 | pageUrl: 'http://www.example.com' 85 | } 86 | const response = await fetch( 87 | `${oauthAgentBaseUrl}/login/end`, 88 | { 89 | method: 'POST', 90 | headers: { 91 | origin: config.trustedWebOrigins[0], 92 | }, 93 | body: JSON.stringify(payload), 94 | }, 95 | ) 96 | 97 | assert.equal(response.status, 200, 'Incorrect HTTP status') 98 | const body = await response.json() as any 99 | assert.equal(body.isLoggedIn, false, 'Incorrect isLoggedIn value') 100 | assert.equal(body.handled, false, 'Incorrect handled value') 101 | }) 102 | 103 | it('POST request to start login with invalid web origin should return a 401 response', async () => { 104 | 105 | const response = await fetch( 106 | `${oauthAgentBaseUrl}/login/start`, 107 | { 108 | method: 'POST', 109 | headers: { 110 | origin: 'https://malicious-site.com', 111 | }, 112 | }, 113 | ) 114 | 115 | assert.equal(response.status, 401, 'Incorrect HTTP status') 116 | const body = await response.json() as any 117 | assert.equal(body.code, 'unauthorized_request', 'Incorrect error code') 118 | }) 119 | 120 | it('Request to start login should return authorization request URL', async () => { 121 | 122 | const response = await fetch( 123 | `${oauthAgentBaseUrl}/login/start`, 124 | { 125 | method: 'POST', 126 | headers: { 127 | origin: config.trustedWebOrigins[0], 128 | }, 129 | }, 130 | ) 131 | 132 | assert.equal(response.status, 200, 'Incorrect HTTP status') 133 | const body = await response.json() as any 134 | const authorizationRequestUrl = body.authorizationRequestUrl as string 135 | expect(authorizationRequestUrl).contains(`client_id=${config.clientID}`, 'Invalid authorization request URL') 136 | }) 137 | 138 | it('Posting a code flow response to login end should result in authenticating the user', async () => { 139 | 140 | const [status, body, cookieString] = await performLogin() 141 | 142 | assert.equal(status, 200, 'Incorrect HTTP status') 143 | expect(cookieString, 'Missing secure cookies').length.above(0) 144 | assert.equal(body.isLoggedIn, true, 'Incorrect isLoggedIn value') 145 | assert.equal(body.handled, true, 'Incorrect handled value') 146 | expect(body.csrf, 'Missing csrfToken value').length.above(0) 147 | }) 148 | 149 | it('Posting a code flow response with malicous state to login end should return a 400 invalid_request response', async () => { 150 | 151 | const [status, body] = await performLogin('ad0316c6-b4e8-11ec-b909-0242ac120002') 152 | 153 | assert.equal(status, 400, 'Incorrect HTTP status') 154 | assert.equal(body.code, 'invalid_request', 'Incorrect error code') 155 | }) 156 | 157 | it("Posting to end login with session cookies should return proper 200 response", async () => { 158 | 159 | const [, , cookieString] = await performLogin() 160 | 161 | const payload = { 162 | pageUrl: 'http://www.example.com', 163 | } 164 | const response = await fetch( 165 | `${oauthAgentBaseUrl}/login/end`, 166 | { 167 | method: 'POST', 168 | headers: { 169 | origin: config.trustedWebOrigins[0], 170 | cookie: cookieString, 171 | }, 172 | body: JSON.stringify(payload), 173 | }, 174 | ) 175 | 176 | assert.equal(response.status, 200, 'Incorrect HTTP status') 177 | const body = await response.json() as any 178 | assert.equal(body.isLoggedIn, true, 'Incorrect isLoggedIn value') 179 | assert.equal(body.handled, false, 'Incorrect handled value') 180 | expect(body.csrf, 'Missing csrfToken value').length.above(0) 181 | }) 182 | 183 | it('When the Authorization Server returns a configuration-related 400 response, the OAuth Agent should respond with 400', async () => { 184 | 185 | const [state, cookieString] = await startLogin() 186 | const code = '4a4246d6-b4bd-11ec-b909-0242ac120002' 187 | 188 | const payload = { 189 | pageUrl: `http://www.example.com?code=${code}&state=${state}`, 190 | } 191 | const options = { 192 | method: 'POST', 193 | headers: { 194 | origin: config.trustedWebOrigins[0], 195 | 'Content-Type': 'application/json', 196 | cookie: cookieString, 197 | }, 198 | body: JSON.stringify(payload) as string, 199 | } as RequestInit 200 | 201 | const stubbedResponse = { 202 | id: '1527eaa0-6af2-45c2-a2b2-e433eaf7cf04', 203 | priority: 1, 204 | request: { 205 | method: 'POST', 206 | url: '/oauth/v2/oauth-token' 207 | }, 208 | response: { 209 | 210 | // Simulate the response for an incorrect client secret to complete the OIDC flow 211 | status: 400, 212 | body: "{\"error\":\"invalid_client\"}" 213 | } 214 | } 215 | 216 | const response = await fetchStubbedResponse(stubbedResponse, async () => { 217 | return await fetch(`${oauthAgentBaseUrl}/login/end`, options) 218 | }) 219 | 220 | assert.equal(response.status, 400, 'Incorrect HTTP status') 221 | const body = await response.json() 222 | assert.equal(body.code, 'authorization_error', 'Incorrect error code') 223 | }) 224 | 225 | it('When the Authorization Server returns an OAuth error response to the front channel, the OAuth Agent should return 400 from login/end', async () => { 226 | 227 | const [state, cookieString] = await startLogin() 228 | 229 | const payload = { 230 | pageUrl: `http://www.example.com?error=invalid_scope&state=${state}`, 231 | } 232 | const options = { 233 | method: 'POST', 234 | headers: { 235 | origin: config.trustedWebOrigins[0], 236 | 'Content-Type': 'application/json', 237 | cookie: cookieString, 238 | }, 239 | body: JSON.stringify(payload), 240 | } as RequestInit 241 | 242 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 243 | 244 | assert.equal(response.status, 400, 'Incorrect HTTP status') 245 | const body = await response.json() 246 | assert.equal(body.code, 'invalid_scope', 'Incorrect error code') 247 | }) 248 | 249 | it('When the Authorization Server returns an expiry-related error response to the front channel, the OAuth Agent should return 401 from login/end', async () => { 250 | 251 | const clientOptions = { 252 | extraParams: [ 253 | { 254 | key: 'prompt', 255 | value: 'none', 256 | } 257 | ] 258 | } 259 | const [state, cookieString] = await startLogin(clientOptions) 260 | 261 | const payload = { 262 | pageUrl: `http://www.example.com?error=login_required&state=${state}`, 263 | } 264 | const options = { 265 | method: 'POST', 266 | headers: { 267 | origin: config.trustedWebOrigins[0], 268 | 'Content-Type': 'application/json', 269 | cookie: cookieString, 270 | }, 271 | body: JSON.stringify(payload), 272 | } as RequestInit 273 | 274 | const response = await fetch(`${oauthAgentBaseUrl}/login/end`, options) 275 | 276 | assert.equal(response.status, 401, 'Incorrect HTTP status') 277 | const body = await response.json() 278 | assert.equal(body.code, 'login_required', 'Incorrect error code') 279 | }) 280 | }) 281 | -------------------------------------------------------------------------------- /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 2022 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 | --------------------------------------------------------------------------------