mirror of https://github.com/boxyhq/jackson.git
Next.js service (#53)
* moved jackson-next to this repo * fixed working-directory * updated package-lock * fixed docker build * fixed dockerfile * cleanup * save npm version for use in the build step * switching the order * fixed env secret * update saml-jackson to the current version before building the next.js service * build from typescript and change main and types before publishing npm * copy README.md from root before publishing npm * update README only for prod versions * read version from root package.json file * fixed artifact * updated package-lock
This commit is contained in:
parent
40706fd8d6
commit
3754f2b13d
12
nodemon.json
12
nodemon.json
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"restartable": "rs",
|
||||
"ignore": [".git", "node_modules/", "dist/", "coverage/"],
|
||||
"watch": ["src/"],
|
||||
"execMap": {
|
||||
"ts": "node -r ts-node/register"
|
||||
},
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"ext": "js,json,ts"
|
||||
}
|
|
@ -1,2 +1,6 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.github
|
||||
_dev
|
||||
.vscode
|
||||
|
|
|
@ -14,5 +14,6 @@ module.exports = {
|
|||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
};
|
||||
|
|
|
@ -72,22 +72,44 @@ jobs:
|
|||
scope: '@boxyhq'
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
working-directory: ./npm
|
||||
- run: npm run test
|
||||
working-directory: ./npm
|
||||
- run: |
|
||||
publishTag="latest"
|
||||
|
||||
npm install -g json
|
||||
JACKSON_VERSION=$(echo $(cat ../package.json) | json version)
|
||||
echo ver=$JACKSON_VERSION
|
||||
json -I -f package.json -e "this.main=\"dist/index.js\""
|
||||
json -I -f package.json -e "this.types=\"dist/index.d.ts\""
|
||||
json -I -f package.json -e "this.version=\"${JACKSON_VERSION}\""
|
||||
|
||||
if [[ "$GITHUB_REF" == *\/release ]]
|
||||
then
|
||||
echo "Release branch"
|
||||
cp ../README.md .
|
||||
else
|
||||
echo "Dev branch"
|
||||
publishTag="beta"
|
||||
versionSuffixTag="-beta.${GITHUB_RUN_NUMBER}"
|
||||
sed "s/\(^[ ]*\"version\"\:[ ]*\".*\)\",/\1${versionSuffixTag}\",/" < package.json > package.json.new
|
||||
mv package.json.new package.json
|
||||
JACKSON_VERSION="${JACKSON_VERSION}-beta.${GITHUB_RUN_NUMBER}"
|
||||
json -I -f package.json -e "this.version=\"${JACKSON_VERSION}\""
|
||||
fi
|
||||
|
||||
npm publish --tag $publishTag --access public
|
||||
|
||||
echo ${JACKSON_VERSION} > npmversion.txt
|
||||
|
||||
echo $(cat npmversion.txt)
|
||||
working-directory: ./npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Upload saml-jackson npm version
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: npmversion
|
||||
path: ./npm/npmversion.txt
|
||||
|
||||
build:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -100,6 +122,20 @@ jobs:
|
|||
id: slug
|
||||
run: echo "::set-output name=sha7::$(echo ${GITHUB_SHA} | cut -c1-7)"
|
||||
|
||||
- name: Download saml-jackson npm version
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: npmversion
|
||||
|
||||
- name: Get saml-jackson npm version
|
||||
id: npmversion
|
||||
run: echo "::set-output name=npmversion::$(cat npmversion.txt)"
|
||||
|
||||
- run: echo ${{ steps.npmversion.outputs.npmversion }}
|
||||
|
||||
# - name: Update @boxyhq/saml-jackson
|
||||
# run: npm install @boxyhq/saml-jackson@${{ steps.npmversion.outputs.npmversion }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
@ -117,7 +153,7 @@ jobs:
|
|||
context: ./
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ github.repository }}:latest,${{ github.repository }}:${{ steps.slug.outputs.sha7 }}
|
||||
tags: ${{ github.repository }}:latest,${{ github.repository }}:${{ steps.slug.outputs.sha7 }},${{ github.repository }}:${{ steps.npmversion.outputs.npmversion }}
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
|
|
@ -1,6 +1,39 @@
|
|||
.vscode
|
||||
node_modules/**
|
||||
.nyc_output
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
_config
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
node_modules
|
||||
.next
|
||||
public
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
||||
.gitignore
|
||||
.npmignore
|
||||
.prettierignore
|
||||
.DS_Store
|
||||
.eslintignore
|
|
@ -0,0 +1,11 @@
|
|||
module.exports = {
|
||||
bracketSpacing: true,
|
||||
bracketSameLine: true,
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
trailingComma: "es5",
|
||||
semi: true,
|
||||
printWidth: 110,
|
||||
arrowParens: "always",
|
||||
importOrderSeparation: true,
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
38
Dockerfile
38
Dockerfile
|
@ -1,12 +1,19 @@
|
|||
# Install dependencies only when needed
|
||||
FROM node:16.13.1-alpine3.14 AS build
|
||||
FROM node:16.13.1-alpine3.14 AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY src/ src/
|
||||
COPY package.json package-lock.json tsconfig*.json ./
|
||||
COPY package.json package-lock.json ./
|
||||
COPY npm npm
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16.13.1-alpine3.14 AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
COPY --from=deps /app/npm ./npm
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
RUN npm run build && npm install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:16.13.1-alpine3.14 AS runner
|
||||
|
@ -16,16 +23,21 @@ ENV NODE_OPTIONS="--max-http-header-size=81920"
|
|||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nodejs -u 1001
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/package-lock.json ./package-lock.json
|
||||
RUN npm ci --only=production
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder /app/npm ./npm
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
USER nodejs
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 5000
|
||||
EXPOSE 6000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "node", "dist/jackson.js" ]
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { DatabaseEngine, DatabaseType } from '@boxyhq/saml-jackson';
|
||||
|
||||
const hostUrl = process.env.HOST_URL || 'localhost';
|
||||
const hostPort = Number(process.env.PORT || '5000');
|
||||
const externalUrl = process.env.EXTERNAL_URL || 'http://' + hostUrl + ':' + hostPort;
|
||||
const samlPath = '/api/oauth/saml';
|
||||
|
||||
const apiKeys = (process.env.JACKSON_API_KEYS || '').split(',');
|
||||
|
||||
const samlAudience = process.env.SAML_AUDIENCE;
|
||||
const preLoadedConfig = process.env.PRE_LOADED_CONFIG;
|
||||
|
||||
const idpEnabled = !!process.env.IDP_ENABLED;
|
||||
const db = {
|
||||
engine: process.env.DB_ENGINE ? <DatabaseEngine>process.env.DB_ENGINE : undefined,
|
||||
url: process.env.DB_URL,
|
||||
type: process.env.DB_TYPE ? <DatabaseType>process.env.DB_TYPE : undefined,
|
||||
ttl: process.env.DB_TTL ? Number(process.env.DB_TTL) : undefined,
|
||||
encryptionKey: process.env.DB_ENCRYPTION_KEY,
|
||||
};
|
||||
|
||||
export default {
|
||||
hostUrl,
|
||||
hostPort,
|
||||
externalUrl,
|
||||
samlPath,
|
||||
samlAudience,
|
||||
preLoadedConfig,
|
||||
apiKeys,
|
||||
idpEnabled,
|
||||
db,
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
import jackson, { IAPIController, IOAuthController } from '@boxyhq/saml-jackson';
|
||||
import env from '@lib/env';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
|
||||
const g = global as any;
|
||||
|
||||
export default async function init() {
|
||||
if (!g.apiController || !g.oauthController) {
|
||||
const ret = await jackson(env);
|
||||
apiController = ret.apiController;
|
||||
oauthController = ret.oauthController;
|
||||
g.apiController = apiController;
|
||||
g.oauthController = oauthController;
|
||||
} else {
|
||||
apiController = g.apiController;
|
||||
oauthController = g.oauthController;
|
||||
}
|
||||
|
||||
return {
|
||||
apiController,
|
||||
oauthController,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import Cors from 'cors';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
// Initializing the cors middleware
|
||||
const corsFunction = Cors({
|
||||
methods: ['GET', 'HEAD'],
|
||||
});
|
||||
|
||||
// Helper method to wait for a middleware to execute before continuing
|
||||
// And to throw an error when an error happens in a middleware
|
||||
function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(req, res, (result: any) => {
|
||||
if (result instanceof Error) {
|
||||
return reject(result);
|
||||
}
|
||||
|
||||
return resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cors(req: NextApiRequest, res: NextApiResponse) {
|
||||
return await runMiddleware(req, res, corsFunction);
|
||||
}
|
11
map.js
11
map.js
|
@ -1,11 +0,0 @@
|
|||
// module.exports = (test) => test.replace(/\.test\.ts$/, '.ts');
|
||||
|
||||
const map = {
|
||||
'api.test.ts': ['src/controller/api.ts'],
|
||||
'oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*'],
|
||||
'db.test.ts': ['src/db/*'],
|
||||
};
|
||||
|
||||
module.exports = (testFile) => {
|
||||
return map[testFile.split('/')[2]];
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 13,
|
||||
sourceType: 'module',
|
||||
},
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.vscode
|
||||
.nyc_output
|
||||
_config
|
||||
dist
|
||||
.DS_Store
|
||||
/node_modules
|
||||
**/node_modules/**
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -0,0 +1,9 @@
|
|||
const map = {
|
||||
'test/api.test.ts': ['src/controller/api.ts'],
|
||||
'test/oauth.test.ts': ['src/controller/oauth.ts', 'src/controller/oauth/*'],
|
||||
'test/db.test.ts': ['src/db/*'],
|
||||
};
|
||||
|
||||
module.exports = (testFile) => {
|
||||
return map[testFile];
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "@boxyhq/saml-jackson",
|
||||
"version": "do-not-change",
|
||||
"description": "SAML 2.0 service",
|
||||
"keywords": [
|
||||
"SAML 2.0"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/boxyhq/jackson.git"
|
||||
},
|
||||
"license": "Apache 2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"migration"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"db:migration:generate": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "tap --ts --timeout=100 --coverage test/**/*.test.ts",
|
||||
"sort": "npx sort-package-json"
|
||||
},
|
||||
"tap": {
|
||||
"branches": 50,
|
||||
"coverage-map": "map.js",
|
||||
"functions": 70,
|
||||
"lines": 70,
|
||||
"statements": 70
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml20": "0.2.0",
|
||||
"@peculiar/webcrypto": "1.2.3",
|
||||
"@peculiar/x509": "1.6.1",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.17.2",
|
||||
"mongodb": "4.2.2",
|
||||
"mysql2": "2.3.3",
|
||||
"pg": "8.7.1",
|
||||
"rambda": "6.9.0",
|
||||
"redis": "4.0.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"ripemd160": "2.0.2",
|
||||
"thumbprint": "0.0.1",
|
||||
"typeorm": "0.2.41",
|
||||
"xml-crypto": "2.1.3",
|
||||
"xml2js": "0.4.23",
|
||||
"xmlbuilder": "15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "4.17.13",
|
||||
"@types/node": "16.11.17",
|
||||
"@types/sinon": "10.0.6",
|
||||
"@types/tap": "15.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.8.1",
|
||||
"@typescript-eslint/parser": "5.8.1",
|
||||
"eslint": "8.5.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"prettier": "2.5.1",
|
||||
"sinon": "12.0.1",
|
||||
"tap": "15.1.5",
|
||||
"ts-node": "10.4.0",
|
||||
"tsconfig-paths": "3.12.0",
|
||||
"typescript": "4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.x"
|
||||
}
|
||||
}
|
|
@ -14,8 +14,7 @@ export class APIController implements IAPIController {
|
|||
}
|
||||
|
||||
private _validateIdPConfig(body: IdPConfig): void {
|
||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } =
|
||||
body;
|
||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||
|
||||
if (!rawMetadata) {
|
||||
throw new JacksonError('Please provide rawMetadata', 400);
|
||||
|
@ -39,8 +38,7 @@ export class APIController implements IAPIController {
|
|||
}
|
||||
|
||||
public async config(body: IdPConfig): Promise<OAuth> {
|
||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } =
|
||||
body;
|
||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||
|
||||
this._validateIdPConfig(body);
|
||||
|
||||
|
@ -49,16 +47,12 @@ export class APIController implements IAPIController {
|
|||
// extract provider
|
||||
let providerName = extractHostName(idpMetadata.entityID);
|
||||
if (!providerName) {
|
||||
providerName = extractHostName(
|
||||
idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl
|
||||
);
|
||||
providerName = extractHostName(idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl);
|
||||
}
|
||||
|
||||
idpMetadata.provider = providerName ? providerName : 'Unknown';
|
||||
|
||||
const clientID = dbutils.keyDigest(
|
||||
dbutils.keyFromParts(tenant, product, idpMetadata.entityID)
|
||||
);
|
||||
const clientID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, idpMetadata.entityID));
|
||||
|
||||
let clientSecret;
|
||||
|
||||
|
@ -133,10 +127,7 @@ export class APIController implements IAPIController {
|
|||
return { provider: samlConfigs[0].idpMetadata.provider };
|
||||
}
|
||||
|
||||
throw new JacksonError(
|
||||
'Please provide `clientID` or `tenant` and `product`.',
|
||||
400
|
||||
);
|
||||
throw new JacksonError('Please provide `clientID` or `tenant` and `product`.', 400);
|
||||
}
|
||||
|
||||
public async deleteConfig(body: {
|
||||
|
@ -180,10 +171,7 @@ export class APIController implements IAPIController {
|
|||
return;
|
||||
}
|
||||
|
||||
throw new JacksonError(
|
||||
'Please provide `clientID` and `clientSecret` or `tenant` and `product`.',
|
||||
400
|
||||
);
|
||||
throw new JacksonError('Please provide `clientID` and `clientSecret` or `tenant` and `product`.', 400);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,7 @@ import { IndexNames } from './utils';
|
|||
|
||||
const relayStatePrefix = 'boxyhq_jackson_';
|
||||
|
||||
function getEncodedClientId(
|
||||
client_id: string
|
||||
): { tenant: string | null; product: string | null } | null {
|
||||
function getEncodedClientId(client_id: string): { tenant: string | null; product: string | null } | null {
|
||||
try {
|
||||
const sp = new URLSearchParams(client_id);
|
||||
const tenant = sp.get('tenant');
|
||||
|
@ -54,9 +52,7 @@ export class OAuthController implements IOAuthController {
|
|||
this.opts = opts;
|
||||
}
|
||||
|
||||
public async authorize(
|
||||
body: OAuthReqBody
|
||||
): Promise<{ redirect_url: string }> {
|
||||
public async authorize(body: OAuthReqBody): Promise<{ redirect_url: string }> {
|
||||
const {
|
||||
response_type = 'code',
|
||||
client_id,
|
||||
|
@ -75,10 +71,7 @@ export class OAuthController implements IOAuthController {
|
|||
}
|
||||
|
||||
if (!state) {
|
||||
throw new JacksonError(
|
||||
'Please specify a state to safeguard against XSRF attacks.',
|
||||
400
|
||||
);
|
||||
throw new JacksonError('Please specify a state to safeguard against XSRF attacks.', 400);
|
||||
}
|
||||
|
||||
let samlConfig;
|
||||
|
@ -95,12 +88,7 @@ export class OAuthController implements IOAuthController {
|
|||
|
||||
// TODO: Support multiple matches
|
||||
samlConfig = samlConfigs[0];
|
||||
} else if (
|
||||
client_id &&
|
||||
client_id !== '' &&
|
||||
client_id !== 'undefined' &&
|
||||
client_id !== 'null'
|
||||
) {
|
||||
} else if (client_id && client_id !== '' && client_id !== 'undefined' && client_id !== 'null') {
|
||||
// if tenant and product are encoded in the client_id then we parse it and check for the relevant config(s)
|
||||
const sp = getEncodedClientId(client_id);
|
||||
if (sp?.tenant) {
|
||||
|
@ -119,10 +107,7 @@ export class OAuthController implements IOAuthController {
|
|||
samlConfig = await this.configStore.get(client_id);
|
||||
}
|
||||
} else {
|
||||
throw new JacksonError(
|
||||
'You need to specify client_id or tenant & product',
|
||||
403
|
||||
);
|
||||
throw new JacksonError('You need to specify client_id or tenant & product', 403);
|
||||
}
|
||||
|
||||
if (!samlConfig) {
|
||||
|
@ -150,20 +135,15 @@ export class OAuthController implements IOAuthController {
|
|||
code_challenge_method,
|
||||
});
|
||||
|
||||
const redirectUrl = redirect.success(
|
||||
samlConfig.idpMetadata.sso.redirectUrl,
|
||||
{
|
||||
RelayState: relayStatePrefix + sessionId,
|
||||
SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
|
||||
}
|
||||
);
|
||||
const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
|
||||
RelayState: relayStatePrefix + sessionId,
|
||||
SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
|
||||
});
|
||||
|
||||
return { redirect_url: redirectUrl };
|
||||
}
|
||||
|
||||
public async samlResponse(
|
||||
body: SAMLResponsePayload
|
||||
): Promise<{ redirect_url: string }> {
|
||||
public async samlResponse(body: SAMLResponsePayload): Promise<{ redirect_url: string }> {
|
||||
const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
|
||||
|
||||
let RelayState = body.RelayState || '';
|
||||
|
@ -204,10 +184,7 @@ export class OAuthController implements IOAuthController {
|
|||
if (RelayState !== '') {
|
||||
session = await this.sessionStore.get(RelayState);
|
||||
if (!session) {
|
||||
throw new JacksonError(
|
||||
'Unable to validate state from the origin request.',
|
||||
403
|
||||
);
|
||||
throw new JacksonError('Unable to validate state from the origin request.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -237,11 +214,7 @@ export class OAuthController implements IOAuthController {
|
|||
|
||||
await this.codeStore.put(code, codeVal);
|
||||
|
||||
if (
|
||||
session &&
|
||||
session.redirect_uri &&
|
||||
!allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)
|
||||
) {
|
||||
if (session && session.redirect_uri && !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)) {
|
||||
throw new JacksonError('Redirect URL is not allowed.', 403);
|
||||
}
|
||||
|
||||
|
@ -262,13 +235,7 @@ export class OAuthController implements IOAuthController {
|
|||
}
|
||||
|
||||
public async token(body: OAuthTokenReq): Promise<OAuthTokenRes> {
|
||||
const {
|
||||
client_id,
|
||||
client_secret,
|
||||
code_verifier,
|
||||
code,
|
||||
grant_type = 'authorization_code',
|
||||
} = body;
|
||||
const { client_id, client_secret, code_verifier, code, grant_type = 'authorization_code' } = body;
|
||||
|
||||
if (grant_type !== 'authorization_code') {
|
||||
throw new JacksonError('Unsupported grant_type', 400);
|
||||
|
@ -289,10 +256,7 @@ export class OAuthController implements IOAuthController {
|
|||
const sp = getEncodedClientId(client_id);
|
||||
if (!sp) {
|
||||
// OAuth flow
|
||||
if (
|
||||
client_id !== codeVal.clientID ||
|
||||
client_secret !== codeVal.clientSecret
|
||||
) {
|
||||
if (client_id !== codeVal.clientID || client_secret !== codeVal.clientSecret) {
|
||||
throw new JacksonError('Invalid client_id or client_secret', 401);
|
||||
}
|
||||
}
|
||||
|
@ -308,10 +272,7 @@ export class OAuthController implements IOAuthController {
|
|||
throw new JacksonError('Invalid code_verifier', 401);
|
||||
}
|
||||
} else if (codeVal && codeVal.session) {
|
||||
throw new JacksonError(
|
||||
'Please specify client_secret or code_verifier',
|
||||
401
|
||||
);
|
||||
throw new JacksonError('Please specify client_secret or code_verifier', 401);
|
||||
}
|
||||
|
||||
// store details against a token
|
|
@ -1,11 +1,4 @@
|
|||
import {
|
||||
DatabaseDriver,
|
||||
DatabaseOption,
|
||||
Encrypted,
|
||||
EncryptionKey,
|
||||
Index,
|
||||
Storable,
|
||||
} from '../typings';
|
||||
import { DatabaseDriver, DatabaseOption, Encrypted, EncryptionKey, Index, Storable } from '../typings';
|
||||
import * as encrypter from './encrypter';
|
||||
import mem from './mem';
|
||||
import mongo from './mongo';
|
||||
|
@ -15,9 +8,7 @@ import store from './store';
|
|||
|
||||
const decrypt = (res: Encrypted, encryptionKey: EncryptionKey): unknown => {
|
||||
if (res.iv && res.tag) {
|
||||
return JSON.parse(
|
||||
encrypter.decrypt(res.value, res.iv, res.tag, encryptionKey)
|
||||
);
|
||||
return JSON.parse(encrypter.decrypt(res.value, res.iv, res.tag, encryptionKey));
|
||||
}
|
||||
|
||||
return JSON.parse(res.value);
|
||||
|
@ -51,13 +42,7 @@ class DB implements DatabaseDriver {
|
|||
}
|
||||
|
||||
// ttl is in seconds
|
||||
async put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: unknown,
|
||||
ttl = 0,
|
||||
...indexes: Index[]
|
||||
): Promise<unknown> {
|
||||
async put(namespace: string, key: string, val: unknown, ttl = 0, ...indexes: Index[]): Promise<unknown> {
|
||||
if (ttl > 0 && indexes && indexes.length > 0) {
|
||||
throw new Error('secondary indexes not allow on a store with ttl');
|
||||
}
|
||||
|
@ -78,11 +63,9 @@ class DB implements DatabaseDriver {
|
|||
}
|
||||
}
|
||||
|
||||
export = {
|
||||
export default {
|
||||
new: async (options: DatabaseOption) => {
|
||||
const encryptionKey = options.encryptionKey
|
||||
? Buffer.from(options.encryptionKey, 'latin1')
|
||||
: null;
|
||||
const encryptionKey = options.encryptionKey ? Buffer.from(options.encryptionKey, 'latin1') : null;
|
||||
|
||||
switch (options.engine) {
|
||||
case 'redis':
|
|
@ -18,17 +18,8 @@ export const encrypt = (text: string, key: EncryptionKey): Encrypted => {
|
|||
};
|
||||
};
|
||||
|
||||
export const decrypt = (
|
||||
ciphertext: string,
|
||||
iv: string,
|
||||
tag: string,
|
||||
key: EncryptionKey
|
||||
): string => {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGO,
|
||||
key,
|
||||
Buffer.from(iv, 'base64')
|
||||
);
|
||||
export const decrypt = (ciphertext: string, iv: string, tag: string, key: EncryptionKey): string => {
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
|
||||
let cleartext = decipher.update(ciphertext, 'base64', 'utf8');
|
|
@ -62,13 +62,7 @@ class Mem implements DatabaseDriver {
|
|||
return ret;
|
||||
}
|
||||
|
||||
async put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: Encrypted,
|
||||
ttl = 0,
|
||||
...indexes: any[]
|
||||
): Promise<any> {
|
||||
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<any> {
|
||||
const k = dbutils.key(namespace, key);
|
||||
|
||||
this.store[k] = val;
|
|
@ -27,10 +27,7 @@ class Mongo implements DatabaseDriver {
|
|||
this.collection = this.db.collection('jacksonStore');
|
||||
|
||||
await this.collection.createIndex({ indexes: 1 });
|
||||
await this.collection.createIndex(
|
||||
{ expiresAt: 1 },
|
||||
{ expireAfterSeconds: 1 }
|
||||
);
|
||||
await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 1 });
|
||||
|
||||
return this;
|
||||
}
|
||||
|
@ -61,13 +58,7 @@ class Mongo implements DatabaseDriver {
|
|||
return ret;
|
||||
}
|
||||
|
||||
async put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: Encrypted,
|
||||
ttl = 0,
|
||||
...indexes: any[]
|
||||
): Promise<void> {
|
||||
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||
const doc = <Document>{
|
||||
value: val,
|
||||
};
|
|
@ -20,9 +20,7 @@ class Redis implements DatabaseDriver {
|
|||
}
|
||||
|
||||
this.client = redis.createClient(opts);
|
||||
this.client.on('error', (err: any) =>
|
||||
console.log('Redis Client Error', err)
|
||||
);
|
||||
this.client.on('error', (err: any) => console.log('Redis Client Error', err));
|
||||
|
||||
await this.client.connect();
|
||||
|
||||
|
@ -39,9 +37,7 @@ class Redis implements DatabaseDriver {
|
|||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||
const dbKeys = await this.client.sMembers(
|
||||
dbutils.keyForIndex(namespace, idx)
|
||||
);
|
||||
const dbKeys = await this.client.sMembers(dbutils.keyForIndex(namespace, idx));
|
||||
|
||||
const ret: string[] = [];
|
||||
for (const dbKey of dbKeys || []) {
|
||||
|
@ -51,13 +47,7 @@ class Redis implements DatabaseDriver {
|
|||
return ret;
|
||||
}
|
||||
|
||||
async put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: Encrypted,
|
||||
ttl = 0,
|
||||
...indexes: any[]
|
||||
): Promise<void> {
|
||||
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||
let tx = this.client.multi();
|
||||
const k = dbutils.key(namespace, key);
|
||||
|
|
@ -2,12 +2,7 @@
|
|||
|
||||
require('reflect-metadata');
|
||||
|
||||
import {
|
||||
DatabaseDriver,
|
||||
DatabaseOption,
|
||||
Index,
|
||||
Encrypted,
|
||||
} from '../../typings';
|
||||
import { DatabaseDriver, DatabaseOption, Index, Encrypted } from '../../typings';
|
||||
import { Connection, createConnection } from 'typeorm';
|
||||
import * as dbutils from '../utils';
|
||||
|
|
@ -28,13 +28,7 @@ class Store implements Storable {
|
|||
return idx;
|
||||
});
|
||||
|
||||
return await this.db.put(
|
||||
this.namespace,
|
||||
dbutils.keyDigest(key),
|
||||
val,
|
||||
this.ttl,
|
||||
...indexes
|
||||
);
|
||||
return await this.db.put(this.namespace, dbutils.keyDigest(key), val, this.ttl, ...indexes);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<any> {
|
|
@ -23,8 +23,7 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
|
|||
|
||||
newOpts.db = newOpts.db || {};
|
||||
newOpts.db.engine = newOpts.db.engine || 'sql';
|
||||
newOpts.db.url =
|
||||
newOpts.db.url || 'postgresql://postgres:postgres@localhost:5432/postgres';
|
||||
newOpts.db.url = newOpts.db.url || 'postgresql://postgres:postgres@localhost:5432/postgres';
|
||||
newOpts.db.type = newOpts.db.type || 'postgres'; // Only needed if DB_ENGINE is sql.
|
||||
newOpts.db.ttl = (newOpts.db.ttl || 300) * 1; // TTL for the code, session and token stores (in seconds)
|
||||
newOpts.db.cleanupLimit = (newOpts.db.cleanupLimit || 1000) * 1; // Limit cleanup of TTL entries to this many items at a time
|
||||
|
@ -64,14 +63,11 @@ export const controllers = async (
|
|||
for (const config of configs) {
|
||||
await apiController.config(config);
|
||||
|
||||
console.log(
|
||||
`loaded config for tenant "${config.tenant}" and product "${config.product}"`
|
||||
);
|
||||
console.log(`loaded config for tenant "${config.tenant}" and product "${config.product}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const type =
|
||||
opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
|
||||
const type = opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
|
||||
|
||||
console.log(`Using engine: ${opts.db.engine}.${type}`);
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
const mapping = [
|
||||
{
|
||||
attribute: 'id',
|
||||
schema:
|
||||
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
|
||||
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
|
||||
},
|
||||
{
|
||||
attribute: 'email',
|
||||
schema:
|
||||
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||
},
|
||||
{
|
||||
attribute: 'firstName',
|
|
@ -0,0 +1,197 @@
|
|||
import saml from '@boxyhq/saml20';
|
||||
import xml2js from 'xml2js';
|
||||
import thumbprint from 'thumbprint';
|
||||
import xmlcrypto from 'xml-crypto';
|
||||
import * as rambda from 'rambda';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import crypto from 'crypto';
|
||||
import claims from './claims';
|
||||
import { SAMLProfile, SAMLReq } from '../typings';
|
||||
|
||||
const idPrefix = '_';
|
||||
const authnXPath =
|
||||
'/*[local-name(.)="AuthnRequest" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]';
|
||||
const issuerXPath = '/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]';
|
||||
|
||||
const signRequest = (xml: string, signingKey: string) => {
|
||||
if (!xml) {
|
||||
throw new Error('Please specify xml');
|
||||
}
|
||||
if (!signingKey) {
|
||||
throw new Error('Please specify signingKey');
|
||||
}
|
||||
|
||||
const sig = new xmlcrypto.SignedXml();
|
||||
sig.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
|
||||
sig.signingKey = signingKey;
|
||||
sig.addReference(
|
||||
authnXPath,
|
||||
['http://www.w3.org/2000/09/xmldsig#enveloped-signature', 'http://www.w3.org/2001/10/xml-exc-c14n#'],
|
||||
'http://www.w3.org/2001/04/xmlenc#sha256'
|
||||
);
|
||||
sig.computeSignature(xml, {
|
||||
location: { reference: authnXPath + issuerXPath, action: 'after' },
|
||||
});
|
||||
|
||||
return sig.getSignedXml();
|
||||
};
|
||||
|
||||
const request = ({
|
||||
ssoUrl,
|
||||
entityID,
|
||||
callbackUrl,
|
||||
isPassive = false,
|
||||
forceAuthn = false,
|
||||
identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
providerName = 'BoxyHQ',
|
||||
signingKey,
|
||||
}: SAMLReq): { id: string; request: string } => {
|
||||
const id = idPrefix + crypto.randomBytes(10).toString('hex');
|
||||
const date = new Date().toISOString();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const samlReq: Record<string, any> = {
|
||||
'samlp:AuthnRequest': {
|
||||
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'@ID': id,
|
||||
'@Version': '2.0',
|
||||
'@IssueInstant': date,
|
||||
'@ProtocolBinding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
'@Destination': ssoUrl,
|
||||
'saml:Issuer': {
|
||||
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
|
||||
'#text': entityID,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (isPassive) samlReq['samlp:AuthnRequest']['@IsPassive'] = true;
|
||||
|
||||
if (forceAuthn) {
|
||||
samlReq['samlp:AuthnRequest']['@ForceAuthn'] = true;
|
||||
}
|
||||
|
||||
samlReq['samlp:AuthnRequest']['@AssertionConsumerServiceURL'] = callbackUrl;
|
||||
|
||||
samlReq['samlp:AuthnRequest']['samlp:NameIDPolicy'] = {
|
||||
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'@Format': identifierFormat,
|
||||
'@AllowCreate': 'true',
|
||||
};
|
||||
|
||||
if (providerName != null) {
|
||||
samlReq['samlp:AuthnRequest']['@ProviderName'] = providerName;
|
||||
}
|
||||
|
||||
let xml = xmlbuilder.create(samlReq).end({});
|
||||
if (signingKey) {
|
||||
xml = signRequest(xml, signingKey);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
request: xml,
|
||||
};
|
||||
};
|
||||
|
||||
const parseAsync = async (rawAssertion: string): Promise<SAMLProfile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
saml.parse(rawAssertion, function onParseAsync(err: Error, profile: SAMLProfile) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(profile);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const validateAsync = async (rawAssertion: string, options): Promise<SAMLProfile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
saml.validate(rawAssertion, options, function onValidateAsync(err, profile: SAMLProfile) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile && profile.claims) {
|
||||
// we map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
|
||||
profile.claims = claims.map(profile.claims);
|
||||
|
||||
// some providers don't return the id in the assertion, we set it to a sha256 hash of the email
|
||||
if (!profile.claims.id) {
|
||||
profile.claims.id = crypto.createHash('sha256').update(profile.claims.email).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
resolve(profile);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const parseMetadataAsync = async (idpMeta: string): Promise<Record<string, any>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
xml2js.parseString(idpMeta, { tagNameProcessors: [xml2js.processors.stripPrefix] }, (err: Error, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityID = rambda.path('EntityDescriptor.$.entityID', res);
|
||||
let X509Certificate = null;
|
||||
let ssoPostUrl: null | undefined = null;
|
||||
let ssoRedirectUrl: null | undefined = null;
|
||||
let loginType = 'idp';
|
||||
|
||||
let ssoDes: any = rambda.pathOr(null, 'EntityDescriptor.IDPSSODescriptor', res);
|
||||
if (!ssoDes) {
|
||||
ssoDes = rambda.pathOr([], 'EntityDescriptor.SPSSODescriptor', res);
|
||||
if (!ssoDes) {
|
||||
loginType = 'sp';
|
||||
}
|
||||
}
|
||||
|
||||
for (const ssoDesRec of ssoDes) {
|
||||
const keyDes = ssoDesRec['KeyDescriptor'];
|
||||
for (const keyDesRec of keyDes) {
|
||||
if (keyDesRec['$'] && keyDesRec['$'].use === 'signing') {
|
||||
const ki = keyDesRec['KeyInfo'][0];
|
||||
const cd = ki['X509Data'][0];
|
||||
X509Certificate = cd['X509Certificate'][0];
|
||||
}
|
||||
}
|
||||
|
||||
const ssoSvc = ssoDesRec['SingleSignOnService'] || ssoDesRec['AssertionConsumerService'] || [];
|
||||
for (const ssoSvcRec of ssoSvc) {
|
||||
if (rambda.pathOr('', '$.Binding', ssoSvcRec).endsWith('HTTP-POST')) {
|
||||
ssoPostUrl = rambda.path('$.Location', ssoSvcRec);
|
||||
} else if (rambda.pathOr('', '$.Binding', ssoSvcRec).endsWith('HTTP-Redirect')) {
|
||||
ssoRedirectUrl = rambda.path('$.Location', ssoSvcRec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ret: Record<string, any> = {
|
||||
sso: {},
|
||||
};
|
||||
if (entityID) {
|
||||
ret.entityID = entityID;
|
||||
}
|
||||
if (X509Certificate) {
|
||||
ret.thumbprint = thumbprint.calculate(X509Certificate);
|
||||
}
|
||||
if (ssoPostUrl) {
|
||||
ret.sso.postUrl = ssoPostUrl;
|
||||
}
|
||||
if (ssoRedirectUrl) {
|
||||
ret.sso.redirectUrl = ssoRedirectUrl;
|
||||
}
|
||||
ret.loginType = loginType;
|
||||
|
||||
resolve(ret);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default { request, parseAsync, validateAsync, parseMetadataAsync };
|
|
@ -14,17 +14,11 @@ const alg = {
|
|||
const generate = async () => {
|
||||
const keys = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||
|
||||
const extensions: x509.Extension[] = [
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
];
|
||||
const extensions: x509.Extension[] = [new x509.BasicConstraintsExtension(false, undefined, true)];
|
||||
|
||||
extensions.push(
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true)
|
||||
);
|
||||
extensions.push(new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true));
|
||||
if (keys.publicKey) {
|
||||
extensions.push(
|
||||
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey)
|
||||
);
|
||||
extensions.push(await x509.SubjectKeyIdentifierExtension.create(keys.publicKey));
|
||||
}
|
||||
|
||||
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
@ -14,13 +14,7 @@ export interface OAuth {
|
|||
|
||||
export interface IAPIController {
|
||||
config(body: IdPConfig): Promise<OAuth>;
|
||||
|
||||
getConfig(body: {
|
||||
clientID?: string;
|
||||
tenant?: string;
|
||||
product?: string;
|
||||
}): Promise<Partial<OAuth>>;
|
||||
|
||||
getConfig(body: { clientID?: string; tenant?: string; product?: string }): Promise<Partial<OAuth>>;
|
||||
deleteConfig(body: {
|
||||
clientID?: string;
|
||||
clientSecret?: string;
|
||||
|
@ -81,13 +75,7 @@ export interface Index {
|
|||
|
||||
export interface DatabaseDriver {
|
||||
get(namespace: string, key: string): Promise<any>;
|
||||
put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: any,
|
||||
ttl: number,
|
||||
...indexes: Index[]
|
||||
): Promise<any>;
|
||||
put(namespace: string, key: string, val: any, ttl: number, ...indexes: Index[]): Promise<any>;
|
||||
delete(namespace: string, key: string): Promise<any>;
|
||||
getByIndex(namespace: string, idx: Index): Promise<any>;
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import * as path from 'path';
|
||||
import sinon from 'sinon';
|
||||
import tap from 'tap';
|
||||
import * as dbutils from '../db/utils';
|
||||
import controllers from '../index';
|
||||
import readConfig from '../read-config';
|
||||
import { IdPConfig, JacksonOption } from '../typings';
|
||||
import * as dbutils from '../src/db/utils';
|
||||
import controllers from '../src/index';
|
||||
import readConfig from '../src/read-config';
|
||||
import { IdPConfig, JacksonOption } from '../src/typings';
|
||||
|
||||
let apiController;
|
||||
|
||||
|
@ -182,10 +182,7 @@ tap.test('controller/api', async (t) => {
|
|||
await apiController.getConfig({});
|
||||
t.fail('Expecting Error.');
|
||||
} catch (err: any) {
|
||||
t.match(
|
||||
err.message,
|
||||
'Please provide `clientID` or `tenant` and `product`.'
|
||||
);
|
||||
t.match(err.message, 'Please provide `clientID` or `tenant` and `product`.');
|
||||
}
|
||||
|
||||
// Invalid clientID
|
||||
|
@ -239,10 +236,7 @@ tap.test('controller/api', async (t) => {
|
|||
await apiController.deleteConfig({});
|
||||
t.fail('Expecting Error.');
|
||||
} catch (err: any) {
|
||||
t.match(
|
||||
err.message,
|
||||
'Please provide `clientID` and `clientSecret` or `tenant` and `product`.'
|
||||
);
|
||||
t.match(err.message, 'Please provide `clientID` and `clientSecret` or `tenant` and `product`.');
|
||||
}
|
||||
|
||||
// Invalid clientID or clientSecret
|
|
@ -1,11 +1,6 @@
|
|||
import {
|
||||
DatabaseEngine,
|
||||
DatabaseOption,
|
||||
EncryptionKey,
|
||||
Storable,
|
||||
} from '../typings';
|
||||
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../src/typings';
|
||||
import tap from 'tap';
|
||||
import DB from '../db/db';
|
||||
import DB from '../src/db/db';
|
||||
|
||||
const encryptionKey: EncryptionKey = '3yGrTcnKPBqqHoH3zZMAU6nt4bmIYb2q';
|
||||
|
||||
|
@ -281,9 +276,7 @@ tap.test('dbs', ({ end }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, (2 * ttl + 0.5) * 1000)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, (2 * ttl + 0.5) * 1000));
|
||||
|
||||
const ret1 = await ttlStore.get(record1.id);
|
||||
const ret2 = await ttlStore.get(record2.id);
|
|
@ -8,12 +8,12 @@ import {
|
|||
OAuthReqBody,
|
||||
OAuthTokenReq,
|
||||
SAMLResponsePayload,
|
||||
} from '../typings';
|
||||
} from '../src/typings';
|
||||
import sinon from 'sinon';
|
||||
import tap from 'tap';
|
||||
import { JacksonError } from '../controller/error';
|
||||
import readConfig from '../read-config';
|
||||
import saml from '../saml/saml';
|
||||
import { JacksonError } from '../src/controller/error';
|
||||
import readConfig from '../src/read-config';
|
||||
import saml from '../src/saml/saml';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
|
@ -49,7 +49,7 @@ const addMetadata = async (metadataPath) => {
|
|||
};
|
||||
|
||||
tap.before(async () => {
|
||||
const controller = await (await import('../index')).default(options);
|
||||
const controller = await (await import('../src/index')).default(options);
|
||||
|
||||
apiController = controller.apiController;
|
||||
oauthController = controller.oauthController;
|
||||
|
@ -73,11 +73,7 @@ tap.test('authorize()', async (t) => {
|
|||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(
|
||||
message,
|
||||
'Please specify a redirect URL.',
|
||||
'got expected error message'
|
||||
);
|
||||
t.equal(message, 'Please specify a redirect URL.', 'got expected error message');
|
||||
t.equal(statusCode, 400, 'got expected status code');
|
||||
}
|
||||
|
||||
|
@ -120,43 +116,32 @@ tap.test('authorize()', async (t) => {
|
|||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(
|
||||
message,
|
||||
'SAML configuration not found.',
|
||||
'got expected error message'
|
||||
);
|
||||
t.equal(message, 'SAML configuration not found.', 'got expected error message');
|
||||
t.equal(statusCode, 403, 'got expected status code');
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
'Should throw an error if `redirect_uri` is not allowed',
|
||||
async (t) => {
|
||||
const body = {
|
||||
redirect_uri: 'https://example.com/',
|
||||
state: 'state-123',
|
||||
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
||||
};
|
||||
t.test('Should throw an error if `redirect_uri` is not allowed', async (t) => {
|
||||
const body = {
|
||||
redirect_uri: 'https://example.com/',
|
||||
state: 'state-123',
|
||||
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await oauthController.authorize(<OAuthReqBody>body);
|
||||
try {
|
||||
await oauthController.authorize(<OAuthReqBody>body);
|
||||
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(
|
||||
message,
|
||||
'Redirect URL is not allowed.',
|
||||
'got expected error message'
|
||||
);
|
||||
t.equal(statusCode, 403, 'got expected status code');
|
||||
}
|
||||
|
||||
t.end();
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(message, 'Redirect URL is not allowed.', 'got expected error message');
|
||||
t.equal(statusCode, 403, 'got expected status code');
|
||||
}
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should return the Idp SSO URL', async (t) => {
|
||||
const body = {
|
||||
|
@ -185,18 +170,11 @@ tap.test('samlResponse()', async (t) => {
|
|||
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
||||
};
|
||||
|
||||
const { redirect_url } = await oauthController.authorize(
|
||||
<OAuthReqBody>authBody
|
||||
);
|
||||
const { redirect_url } = await oauthController.authorize(<OAuthReqBody>authBody);
|
||||
|
||||
const relayState = new URLSearchParams(new URL(redirect_url).search).get(
|
||||
'RelayState'
|
||||
);
|
||||
const relayState = new URLSearchParams(new URL(redirect_url).search).get('RelayState');
|
||||
|
||||
const rawResponse = await fs.readFile(
|
||||
path.join(__dirname, '/data/saml_response'),
|
||||
'utf8'
|
||||
);
|
||||
const rawResponse = await fs.readFile(path.join(__dirname, '/data/saml_response'), 'utf8');
|
||||
|
||||
t.test('Should throw an error if `RelayState` is missing', async (t) => {
|
||||
const responseBody: Partial<SAMLResponsePayload> = {
|
||||
|
@ -221,69 +199,57 @@ tap.test('samlResponse()', async (t) => {
|
|||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
'Should return a URL with code and state as query params',
|
||||
async (t) => {
|
||||
const responseBody = {
|
||||
SAMLResponse: rawResponse,
|
||||
RelayState: relayState,
|
||||
};
|
||||
t.test('Should return a URL with code and state as query params', async (t) => {
|
||||
const responseBody = {
|
||||
SAMLResponse: rawResponse,
|
||||
RelayState: relayState,
|
||||
};
|
||||
|
||||
const stubValidateAsync = sinon
|
||||
.stub(saml, 'validateAsync')
|
||||
.resolves({ audience: '', claims: {}, issuer: '', sessionIndex: '' });
|
||||
const stubValidateAsync = sinon
|
||||
.stub(saml, 'validateAsync')
|
||||
.resolves({ audience: '', claims: {}, issuer: '', sessionIndex: '' });
|
||||
|
||||
//@ts-ignore
|
||||
const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(code);
|
||||
//@ts-ignore
|
||||
const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(code);
|
||||
|
||||
const response = await oauthController.samlResponse(
|
||||
<SAMLResponsePayload>responseBody
|
||||
);
|
||||
const response = await oauthController.samlResponse(<SAMLResponsePayload>responseBody);
|
||||
|
||||
const params = new URLSearchParams(new URL(response.redirect_url).search);
|
||||
const params = new URLSearchParams(new URL(response.redirect_url).search);
|
||||
|
||||
t.ok(stubValidateAsync.calledOnce, 'validateAsync called once');
|
||||
t.ok(stubRandomBytes.calledOnce, 'randomBytes called once');
|
||||
t.ok('redirect_url' in response, 'response contains redirect_url');
|
||||
t.ok(params.has('code'), 'query string includes code');
|
||||
t.ok(params.has('state'), 'query string includes state');
|
||||
t.match(params.get('state'), authBody.state, 'state value is valid');
|
||||
t.ok(stubValidateAsync.calledOnce, 'validateAsync called once');
|
||||
t.ok(stubRandomBytes.calledOnce, 'randomBytes called once');
|
||||
t.ok('redirect_url' in response, 'response contains redirect_url');
|
||||
t.ok(params.has('code'), 'query string includes code');
|
||||
t.ok(params.has('state'), 'query string includes state');
|
||||
t.match(params.get('state'), authBody.state, 'state value is valid');
|
||||
|
||||
stubRandomBytes.restore();
|
||||
stubValidateAsync.restore();
|
||||
stubRandomBytes.restore();
|
||||
stubValidateAsync.restore();
|
||||
|
||||
t.end();
|
||||
}
|
||||
);
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
tap.test('token()', (t) => {
|
||||
t.test(
|
||||
'Should throw an error if `grant_type` is not `authorization_code`',
|
||||
async (t) => {
|
||||
const body = {
|
||||
grant_type: 'authorization_code_1',
|
||||
};
|
||||
t.test('Should throw an error if `grant_type` is not `authorization_code`', async (t) => {
|
||||
const body = {
|
||||
grant_type: 'authorization_code_1',
|
||||
};
|
||||
|
||||
try {
|
||||
await oauthController.token(<OAuthTokenReq>body);
|
||||
try {
|
||||
await oauthController.token(<OAuthTokenReq>body);
|
||||
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(
|
||||
message,
|
||||
'Unsupported grant_type',
|
||||
'got expected error message'
|
||||
);
|
||||
t.equal(statusCode, 400, 'got expected status code');
|
||||
}
|
||||
|
||||
t.end();
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err) {
|
||||
const { message, statusCode } = err as JacksonError;
|
||||
t.equal(message, 'Unsupported grant_type', 'got expected error message');
|
||||
t.equal(statusCode, 400, 'got expected status code');
|
||||
}
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Should throw an error if `code` is missing', async (t) => {
|
||||
const body = {
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test/**/*"]
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"allowJs": true,
|
||||
"module": "CommonJS",
|
||||
"target": "es6", //same as es2015
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strict": true,
|
||||
"noImplicitThis": false,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"noEmitOnError": false,
|
||||
"noUnusedParameters": true,
|
||||
"removeComments": false,
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
"files": true
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
118
package.json
118
package.json
|
@ -1,87 +1,61 @@
|
|||
{
|
||||
"name": "@boxyhq/saml-jackson",
|
||||
"version": "0.3.1",
|
||||
"license": "Apache 2.0",
|
||||
"description": "SAML 2.0 service",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"engines": {
|
||||
"node": ">=14.x"
|
||||
},
|
||||
"name": "jackson",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "SAML 2.0 service (next.js)",
|
||||
"keywords": [
|
||||
"SAML 2.0"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/boxyhq/jackson.git"
|
||||
},
|
||||
"keywords": [
|
||||
"SAML 2.0"
|
||||
],
|
||||
"license": "Apache 2.0",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"start": "cross-env IDP_ENABLED=true node dist/jackson.js",
|
||||
"dev": "cross-env IDP_ENABLED=true nodemon --config nodemon.json src/jackson.ts",
|
||||
"mongo": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mongo DB_URL=mongodb://localhost:27017/jackson nodemon --config nodemon.json src/jackson.ts",
|
||||
"sql": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=sql DB_TYPE=postgres DB_URL=postgres://postgres:postgres@localhost:5432/jackson nodemon --config nodemon.json src/jackson.ts",
|
||||
"pre-loaded": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mem PRE_LOADED_CONFIG='./_config' nodemon --config nodemon.json src/jackson.ts",
|
||||
"pre-loaded-db": "cross-env JACKSON_API_KEYS=secret PRE_LOADED_CONFIG='./_config' nodemon --config nodemon.json src/jackson.ts",
|
||||
"test": "tap --ts --timeout=100 --coverage src/**/*.test.ts ",
|
||||
"build": "next build",
|
||||
"dev": "cross-env IDP_ENABLED=true next dev -p 5000",
|
||||
"dev-dbs": "docker-compose -f ./_dev/docker-compose.yml up -d",
|
||||
"dev-dbs-destroy": "docker-compose -f ./_dev/docker-compose.yml down --volumes --remove-orphans",
|
||||
"db:migration:generate": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial"
|
||||
"lint": "next lint",
|
||||
"mongo": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mongo DB_URL=mongodb://localhost:27017/jackson npm run dev",
|
||||
"pre-loaded": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mem PRE_LOADED_CONFIG='./_config' npm run dev",
|
||||
"pre-loaded-db": "cross-env JACKSON_API_KEYS=secret PRE_LOADED_CONFIG='./_config' npm run dev",
|
||||
"sort": "npx sort-package-json",
|
||||
"sql": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=sql DB_TYPE=postgres DB_URL=postgres://postgres:postgres@localhost:5432/jackson npm run dev",
|
||||
"start": "next start -p 5000"
|
||||
},
|
||||
"tap": {
|
||||
"coverage-map": "map.js",
|
||||
"branches": 50,
|
||||
"functions": 70,
|
||||
"lines": 70,
|
||||
"statements": 70
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml20": "0.2.0",
|
||||
"@peculiar/webcrypto": "1.2.3",
|
||||
"@peculiar/x509": "1.6.1",
|
||||
"cors": "2.8.5",
|
||||
"express": "4.17.2",
|
||||
"mongodb": "4.2.2",
|
||||
"mysql2": "2.3.3",
|
||||
"pg": "8.7.1",
|
||||
"rambda": "6.9.0",
|
||||
"redis": "4.0.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"ripemd160": "2.0.2",
|
||||
"thumbprint": "0.0.1",
|
||||
"typeorm": "0.2.41",
|
||||
"xml-crypto": "2.1.3",
|
||||
"xml2js": "0.4.23",
|
||||
"xmlbuilder": "15.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/node": "^16.11.17",
|
||||
"@types/redis": "4.0.11",
|
||||
"@types/sinon": "10.0.6",
|
||||
"@types/tap": "15.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.8.1",
|
||||
"@typescript-eslint/parser": "^5.8.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"husky": "7.0.4",
|
||||
"lint-staged": "12.1.4",
|
||||
"nodemon": "2.0.15",
|
||||
"prettier": "2.5.1",
|
||||
"sinon": "12.0.1",
|
||||
"tap": "15.1.5",
|
||||
"ts-node": "10.4.0",
|
||||
"tsconfig-paths": "3.12.0",
|
||||
"typescript": "4.5.4"
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": "eslint --cache --fix",
|
||||
"*.{js,ts,css,md}": "prettier --write"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"Dockerfile"
|
||||
]
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "file:./npm",
|
||||
"cors": "2.8.5",
|
||||
"next": "12.0.7",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/node": "16.11.12",
|
||||
"@types/react": "17.0.37",
|
||||
"@typescript-eslint/eslint-plugin": "5.8.1",
|
||||
"@typescript-eslint/parser": "5.8.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.4.1",
|
||||
"eslint-config-next": "12.0.7",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"husky": "7.0.4",
|
||||
"lint-staged": "12.1.4",
|
||||
"prettier": "2.5.1",
|
||||
"typescript": "4.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import '../styles/globals.css';
|
||||
import type { AppProps } from 'next/app';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
}
|
||||
|
||||
export default MyApp;
|
|
@ -0,0 +1,10 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
type Data = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
|
||||
res.status(200).json({ name: 'John Doe' });
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { OAuthReqBody } from '@boxyhq/saml-jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReqBody);
|
||||
res.redirect(302, redirect_url);
|
||||
} catch (err: any) {
|
||||
console.error('authorize error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== 'POST') {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
const { redirect_url } = await oauthController.samlResponse(req.body);
|
||||
|
||||
res.redirect(302, redirect_url);
|
||||
} catch (err: any) {
|
||||
console.error('callback error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { cors } from '@lib/middleware';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
await cors(req, res);
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
const result = await oauthController.token(req.body);
|
||||
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('token error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
const extractAuthToken = (req: NextApiRequest) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const parts = (authHeader || '').split(' ');
|
||||
if (parts.length > 1) {
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
|
||||
const { oauthController } = await jackson();
|
||||
let token: string | null = extractAuthToken(req);
|
||||
|
||||
// check for query param
|
||||
if (!token) {
|
||||
let arr: string[] = [];
|
||||
arr = arr.concat(req.query.access_token);
|
||||
if (arr[0].length > 0) {
|
||||
token = arr[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await oauthController.userInfo(token);
|
||||
|
||||
res.json(profile);
|
||||
} catch (err: any) {
|
||||
console.error('userinfo error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const { apiController } = await jackson();
|
||||
if (req.method === 'POST') {
|
||||
res.json(await apiController.config(req.body));
|
||||
} else if (req.method === 'GET') {
|
||||
res.json(await apiController.getConfig(req.query as any));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.status(204).end(await apiController.deleteConfig(req.body));
|
||||
} else {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('config api error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import styles from '../styles/Home.module.css';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name='description' content='Generated by create next app' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href='https://nextjs.org'>Next.js!</a>
|
||||
</h1>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing <code className={styles.code}>pages/index.tsx</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href='https://nextjs.org/docs' className={styles.card}>
|
||||
<h2>Documentation →</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href='https://nextjs.org/learn' className={styles.card}>
|
||||
<h2>Learn →</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a href='https://github.com/vercel/next.js/tree/master/examples' className={styles.card}>
|
||||
<h2>Examples →</h2>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
|
||||
className={styles.card}>
|
||||
<h2>Deploy →</h2>
|
||||
<p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href='https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
Powered by{' '}
|
||||
<span className={styles.logo}>
|
||||
<Image src='/vercel.svg' alt='Vercel Logo' width={72} height={16} />
|
||||
</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -0,0 +1,4 @@
|
|||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
43
src/env.ts
43
src/env.ts
|
@ -1,43 +0,0 @@
|
|||
const hostUrl = process.env.HOST_URL || 'localhost';
|
||||
const hostPort = +(process.env.HOST_PORT || '5000');
|
||||
const externalUrl =
|
||||
process.env.EXTERNAL_URL || 'http://' + hostUrl + ':' + hostPort;
|
||||
const samlPath = process.env.SAML_PATH || '/oauth/saml';
|
||||
|
||||
const internalHostUrl = process.env.INTERNAL_HOST_URL || 'localhost';
|
||||
const internalHostPort = +(process.env.INTERNAL_HOST_PORT || '6000');
|
||||
|
||||
const apiKeys = (process.env.JACKSON_API_KEYS || '').split(',');
|
||||
|
||||
const samlAudience = process.env.SAML_AUDIENCE || 'https://saml.boxyhq.com';
|
||||
const preLoadedConfig = process.env.PRE_LOADED_CONFIG;
|
||||
|
||||
const idpEnabled = process.env.IDP_ENABLED;
|
||||
|
||||
const db = {
|
||||
engine: process.env.DB_ENGINE,
|
||||
url: process.env.DB_URL,
|
||||
type: process.env.DB_TYPE,
|
||||
ttl: process.env.DB_TTL,
|
||||
encryptionKey: process.env.DB_ENCRYPTION_KEY,
|
||||
cleanupLimit: process.env.DB_CLEANUP_LIMIT,
|
||||
};
|
||||
|
||||
const env = {
|
||||
hostUrl,
|
||||
hostPort,
|
||||
externalUrl,
|
||||
samlPath,
|
||||
samlAudience,
|
||||
preLoadedConfig,
|
||||
internalHostUrl,
|
||||
internalHostPort,
|
||||
apiKeys,
|
||||
idpEnabled,
|
||||
db,
|
||||
useInternalServer: !(
|
||||
hostUrl === internalHostUrl && hostPort === internalHostPort
|
||||
),
|
||||
};
|
||||
|
||||
export default env;
|
173
src/jackson.ts
173
src/jackson.ts
|
@ -1,173 +0,0 @@
|
|||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import jackson, { IOAuthController, IAPIController } from './index';
|
||||
import { JacksonError } from './controller/error';
|
||||
import { extractAuthToken } from './controller/utils';
|
||||
import env from './env';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
|
||||
const oauthPath = '/oauth';
|
||||
const apiPath = '/api/v1/saml';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.get(oauthPath + '/authorize', async (req, res) => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const { redirect_url } = await oauthController.authorize(req.query);
|
||||
|
||||
res.redirect(redirect_url);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as JacksonError;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
});
|
||||
|
||||
app.post(env.samlPath, async (req, res) => {
|
||||
try {
|
||||
const { redirect_url } = await oauthController.samlResponse(req.body);
|
||||
|
||||
res.redirect(redirect_url);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as JacksonError;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
});
|
||||
|
||||
app.post(oauthPath + '/token', cors(), async (req, res) => {
|
||||
try {
|
||||
const result = await oauthController.token(req.body);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as JacksonError;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(oauthPath + '/userinfo', async (req, res) => {
|
||||
try {
|
||||
let token = extractAuthToken(req);
|
||||
|
||||
// check for query param
|
||||
if (!token) {
|
||||
// @ts-ignore
|
||||
token = req.query.access_token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const profile = await oauthController.userInfo(token);
|
||||
|
||||
res.json(profile);
|
||||
} catch (err) {
|
||||
const { message, statusCode = 500 } = err as JacksonError;
|
||||
|
||||
res.status(statusCode).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
const server = app.listen(env.hostPort, async () => {
|
||||
console.log(
|
||||
`🚀 The path of the righteous server: http://${env.hostUrl}:${env.hostPort}`
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const ctrlrModule = await jackson(env);
|
||||
|
||||
apiController = ctrlrModule.apiController;
|
||||
oauthController = ctrlrModule.oauthController;
|
||||
});
|
||||
|
||||
// Internal routes, recommended not to expose this to the public interface though it would be guarded by API key(s)
|
||||
let internalApp = app;
|
||||
|
||||
if (env.useInternalServer) {
|
||||
internalApp = express();
|
||||
|
||||
internalApp.use(express.json());
|
||||
internalApp.use(express.urlencoded({ extended: true }));
|
||||
}
|
||||
|
||||
const validateApiKey = (token) => {
|
||||
return env.apiKeys.includes(token);
|
||||
};
|
||||
|
||||
internalApp.post(apiPath + '/config', async (req, res) => {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(await apiController.config(req.body));
|
||||
} catch (err) {
|
||||
const { message } = err as JacksonError;
|
||||
|
||||
res.status(500).json({
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
internalApp.get(apiPath + '/config', async (req, res) => {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
res.json(await apiController.getConfig(req.query));
|
||||
} catch (err) {
|
||||
const { message } = err as JacksonError;
|
||||
|
||||
res.status(500).json({
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
internalApp.delete(apiPath + '/config', async (req, res) => {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
await apiController.deleteConfig(req.body);
|
||||
res.status(200).end();
|
||||
} catch (err) {
|
||||
const { message } = err as JacksonError;
|
||||
|
||||
res.status(500).json({
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let internalServer = server;
|
||||
if (env.useInternalServer) {
|
||||
internalServer = internalApp.listen(env.internalHostPort, async () => {
|
||||
console.log(
|
||||
`🚀 The path of the righteous internal server: http://${env.internalHostUrl}:${env.internalHostPort}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
server,
|
||||
internalServer,
|
||||
};
|
233
src/saml/saml.ts
233
src/saml/saml.ts
|
@ -1,233 +0,0 @@
|
|||
import saml from '@boxyhq/saml20';
|
||||
import xml2js from 'xml2js';
|
||||
import thumbprint from 'thumbprint';
|
||||
import xmlcrypto from 'xml-crypto';
|
||||
import * as rambda from 'rambda';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import crypto from 'crypto';
|
||||
import claims from './claims';
|
||||
import { SAMLProfile, SAMLReq } from '../typings';
|
||||
|
||||
const idPrefix = '_';
|
||||
const authnXPath =
|
||||
'/*[local-name(.)="AuthnRequest" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]';
|
||||
const issuerXPath =
|
||||
'/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]';
|
||||
|
||||
const signRequest = (xml: string, signingKey: string) => {
|
||||
if (!xml) {
|
||||
throw new Error('Please specify xml');
|
||||
}
|
||||
if (!signingKey) {
|
||||
throw new Error('Please specify signingKey');
|
||||
}
|
||||
|
||||
const sig = new xmlcrypto.SignedXml();
|
||||
sig.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
|
||||
sig.signingKey = signingKey;
|
||||
sig.addReference(
|
||||
authnXPath,
|
||||
[
|
||||
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
|
||||
'http://www.w3.org/2001/10/xml-exc-c14n#',
|
||||
],
|
||||
'http://www.w3.org/2001/04/xmlenc#sha256'
|
||||
);
|
||||
sig.computeSignature(xml, {
|
||||
location: { reference: authnXPath + issuerXPath, action: 'after' },
|
||||
});
|
||||
|
||||
return sig.getSignedXml();
|
||||
};
|
||||
|
||||
const request = ({
|
||||
ssoUrl,
|
||||
entityID,
|
||||
callbackUrl,
|
||||
isPassive = false,
|
||||
forceAuthn = false,
|
||||
identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
providerName = 'BoxyHQ',
|
||||
signingKey,
|
||||
}: SAMLReq): { id: string; request: string } => {
|
||||
const id = idPrefix + crypto.randomBytes(10).toString('hex');
|
||||
const date = new Date().toISOString();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const samlReq: Record<string, any> = {
|
||||
'samlp:AuthnRequest': {
|
||||
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'@ID': id,
|
||||
'@Version': '2.0',
|
||||
'@IssueInstant': date,
|
||||
'@ProtocolBinding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
'@Destination': ssoUrl,
|
||||
'saml:Issuer': {
|
||||
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
|
||||
'#text': entityID,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (isPassive) samlReq['samlp:AuthnRequest']['@IsPassive'] = true;
|
||||
|
||||
if (forceAuthn) {
|
||||
samlReq['samlp:AuthnRequest']['@ForceAuthn'] = true;
|
||||
}
|
||||
|
||||
samlReq['samlp:AuthnRequest']['@AssertionConsumerServiceURL'] = callbackUrl;
|
||||
|
||||
samlReq['samlp:AuthnRequest']['samlp:NameIDPolicy'] = {
|
||||
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'@Format': identifierFormat,
|
||||
'@AllowCreate': 'true',
|
||||
};
|
||||
|
||||
if (providerName != null) {
|
||||
samlReq['samlp:AuthnRequest']['@ProviderName'] = providerName;
|
||||
}
|
||||
|
||||
let xml = xmlbuilder.create(samlReq).end({});
|
||||
if (signingKey) {
|
||||
xml = signRequest(xml, signingKey);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
request: xml,
|
||||
};
|
||||
};
|
||||
|
||||
const parseAsync = async (rawAssertion: string): Promise<SAMLProfile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
saml.parse(
|
||||
rawAssertion,
|
||||
function onParseAsync(err: Error, profile: SAMLProfile) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(profile);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const validateAsync = async (
|
||||
rawAssertion: string,
|
||||
options
|
||||
): Promise<SAMLProfile> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
saml.validate(
|
||||
rawAssertion,
|
||||
options,
|
||||
function onValidateAsync(err, profile: SAMLProfile) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile && profile.claims) {
|
||||
// we map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
|
||||
profile.claims = claims.map(profile.claims);
|
||||
|
||||
// some providers don't return the id in the assertion, we set it to a sha256 hash of the email
|
||||
if (!profile.claims.id) {
|
||||
profile.claims.id = crypto
|
||||
.createHash('sha256')
|
||||
.update(profile.claims.email)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
resolve(profile);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const parseMetadataAsync = async (
|
||||
idpMeta: string
|
||||
): Promise<Record<string, any>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
xml2js.parseString(
|
||||
idpMeta,
|
||||
{ tagNameProcessors: [xml2js.processors.stripPrefix] },
|
||||
(err: Error, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityID = rambda.path('EntityDescriptor.$.entityID', res);
|
||||
let X509Certificate = null;
|
||||
let ssoPostUrl: null | undefined = null;
|
||||
let ssoRedirectUrl: null | undefined = null;
|
||||
let loginType = 'idp';
|
||||
|
||||
let ssoDes: any = rambda.pathOr(
|
||||
null,
|
||||
'EntityDescriptor.IDPSSODescriptor',
|
||||
res
|
||||
);
|
||||
if (!ssoDes) {
|
||||
ssoDes = rambda.pathOr([], 'EntityDescriptor.SPSSODescriptor', res);
|
||||
if (!ssoDes) {
|
||||
loginType = 'sp';
|
||||
}
|
||||
}
|
||||
|
||||
for (const ssoDesRec of ssoDes) {
|
||||
const keyDes = ssoDesRec['KeyDescriptor'];
|
||||
for (const keyDesRec of keyDes) {
|
||||
if (keyDesRec['$'] && keyDesRec['$'].use === 'signing') {
|
||||
const ki = keyDesRec['KeyInfo'][0];
|
||||
const cd = ki['X509Data'][0];
|
||||
X509Certificate = cd['X509Certificate'][0];
|
||||
}
|
||||
}
|
||||
|
||||
const ssoSvc =
|
||||
ssoDesRec['SingleSignOnService'] ||
|
||||
ssoDesRec['AssertionConsumerService'] ||
|
||||
[];
|
||||
for (const ssoSvcRec of ssoSvc) {
|
||||
if (
|
||||
rambda.pathOr('', '$.Binding', ssoSvcRec).endsWith('HTTP-POST')
|
||||
) {
|
||||
ssoPostUrl = rambda.path('$.Location', ssoSvcRec);
|
||||
} else if (
|
||||
rambda
|
||||
.pathOr('', '$.Binding', ssoSvcRec)
|
||||
.endsWith('HTTP-Redirect')
|
||||
) {
|
||||
ssoRedirectUrl = rambda.path('$.Location', ssoSvcRec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ret: Record<string, any> = {
|
||||
sso: {},
|
||||
};
|
||||
if (entityID) {
|
||||
ret.entityID = entityID;
|
||||
}
|
||||
if (X509Certificate) {
|
||||
ret.thumbprint = thumbprint.calculate(X509Certificate);
|
||||
}
|
||||
if (ssoPostUrl) {
|
||||
ret.sso.postUrl = ssoPostUrl;
|
||||
}
|
||||
if (ssoRedirectUrl) {
|
||||
ret.sso.redirectUrl = ssoRedirectUrl;
|
||||
}
|
||||
ret.loginType = loginType;
|
||||
|
||||
resolve(ret);
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default { request, parseAsync, validateAsync, parseMetadataAsync };
|
|
@ -0,0 +1,116 @@
|
|||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid #eaeaea;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "**/test/*"],
|
||||
}
|
||||
|
|
@ -1,26 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"module": "CommonJS",
|
||||
"target": "es6", //same as es2015
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false,
|
||||
"strict": true,
|
||||
"noImplicitThis": false,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"noEmitOnError": false,
|
||||
"noUnusedParameters": true,
|
||||
"removeComments": false,
|
||||
"strictNullChecks": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["components/*"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@styles/*": ["styles/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
"files": true
|
||||
}
|
||||
"include": ["next-env.d.ts", "types/*.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue