├── 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