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
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
_dev
|
||||||
|
.vscode
|
||||||
|
|
|
@ -14,5 +14,6 @@ module.exports = {
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'prettier',
|
'prettier',
|
||||||
|
'next/core-web-vitals',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -72,22 +72,44 @@ jobs:
|
||||||
scope: '@boxyhq'
|
scope: '@boxyhq'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- run: npm install
|
- run: npm install
|
||||||
|
working-directory: ./npm
|
||||||
- run: npm run test
|
- run: npm run test
|
||||||
|
working-directory: ./npm
|
||||||
- run: |
|
- run: |
|
||||||
publishTag="latest"
|
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 ]]
|
if [[ "$GITHUB_REF" == *\/release ]]
|
||||||
then
|
then
|
||||||
echo "Release branch"
|
echo "Release branch"
|
||||||
|
cp ../README.md .
|
||||||
else
|
else
|
||||||
echo "Dev branch"
|
echo "Dev branch"
|
||||||
publishTag="beta"
|
publishTag="beta"
|
||||||
versionSuffixTag="-beta.${GITHUB_RUN_NUMBER}"
|
JACKSON_VERSION="${JACKSON_VERSION}-beta.${GITHUB_RUN_NUMBER}"
|
||||||
sed "s/\(^[ ]*\"version\"\:[ ]*\".*\)\",/\1${versionSuffixTag}\",/" < package.json > package.json.new
|
json -I -f package.json -e "this.version=\"${JACKSON_VERSION}\""
|
||||||
mv package.json.new package.json
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm publish --tag $publishTag --access public
|
npm publish --tag $publishTag --access public
|
||||||
|
|
||||||
|
echo ${JACKSON_VERSION} > npmversion.txt
|
||||||
|
|
||||||
|
echo $(cat npmversion.txt)
|
||||||
|
working-directory: ./npm
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
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:
|
build:
|
||||||
needs: publish
|
needs: publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -100,6 +122,20 @@ jobs:
|
||||||
id: slug
|
id: slug
|
||||||
run: echo "::set-output name=sha7::$(echo ${GITHUB_SHA} | cut -c1-7)"
|
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
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
@ -117,7 +153,7 @@ jobs:
|
||||||
context: ./
|
context: ./
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
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
|
- name: Image digest
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
|
|
|
@ -1,6 +1,39 @@
|
||||||
.vscode
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
node_modules/**
|
|
||||||
.nyc_output
|
|
||||||
_config
|
_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
|
# 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.
|
# 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
|
RUN apk add --no-cache libc6-compat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/ src/
|
COPY package.json package-lock.json ./
|
||||||
COPY package.json package-lock.json tsconfig*.json ./
|
COPY npm npm
|
||||||
RUN npm install
|
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
|
# Production image, copy all the files and run next
|
||||||
FROM node:16.13.1-alpine3.14 AS runner
|
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
|
ENV NODE_ENV production
|
||||||
|
|
||||||
RUN addgroup -g 1001 -S nodejs
|
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=builder /app/public ./public
|
||||||
COPY --from=build /app/package.json ./package.json
|
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||||
COPY --from=build /app/package-lock.json ./package-lock.json
|
COPY --from=builder /app/npm ./npm
|
||||||
RUN npm ci --only=production
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
USER nodejs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 3000
|
||||||
EXPOSE 6000
|
|
||||||
|
|
||||||
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 {
|
private _validateIdPConfig(body: IdPConfig): void {
|
||||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } =
|
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||||
body;
|
|
||||||
|
|
||||||
if (!rawMetadata) {
|
if (!rawMetadata) {
|
||||||
throw new JacksonError('Please provide rawMetadata', 400);
|
throw new JacksonError('Please provide rawMetadata', 400);
|
||||||
|
@ -39,8 +38,7 @@ export class APIController implements IAPIController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async config(body: IdPConfig): Promise<OAuth> {
|
public async config(body: IdPConfig): Promise<OAuth> {
|
||||||
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } =
|
const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||||
body;
|
|
||||||
|
|
||||||
this._validateIdPConfig(body);
|
this._validateIdPConfig(body);
|
||||||
|
|
||||||
|
@ -49,16 +47,12 @@ export class APIController implements IAPIController {
|
||||||
// extract provider
|
// extract provider
|
||||||
let providerName = extractHostName(idpMetadata.entityID);
|
let providerName = extractHostName(idpMetadata.entityID);
|
||||||
if (!providerName) {
|
if (!providerName) {
|
||||||
providerName = extractHostName(
|
providerName = extractHostName(idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl);
|
||||||
idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
idpMetadata.provider = providerName ? providerName : 'Unknown';
|
idpMetadata.provider = providerName ? providerName : 'Unknown';
|
||||||
|
|
||||||
const clientID = dbutils.keyDigest(
|
const clientID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, idpMetadata.entityID));
|
||||||
dbutils.keyFromParts(tenant, product, idpMetadata.entityID)
|
|
||||||
);
|
|
||||||
|
|
||||||
let clientSecret;
|
let clientSecret;
|
||||||
|
|
||||||
|
@ -133,10 +127,7 @@ export class APIController implements IAPIController {
|
||||||
return { provider: samlConfigs[0].idpMetadata.provider };
|
return { provider: samlConfigs[0].idpMetadata.provider };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new JacksonError(
|
throw new JacksonError('Please provide `clientID` or `tenant` and `product`.', 400);
|
||||||
'Please provide `clientID` or `tenant` and `product`.',
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteConfig(body: {
|
public async deleteConfig(body: {
|
||||||
|
@ -180,10 +171,7 @@ export class APIController implements IAPIController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new JacksonError(
|
throw new JacksonError('Please provide `clientID` and `clientSecret` or `tenant` and `product`.', 400);
|
||||||
'Please provide `clientID` and `clientSecret` or `tenant` and `product`.',
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,9 +19,7 @@ import { IndexNames } from './utils';
|
||||||
|
|
||||||
const relayStatePrefix = 'boxyhq_jackson_';
|
const relayStatePrefix = 'boxyhq_jackson_';
|
||||||
|
|
||||||
function getEncodedClientId(
|
function getEncodedClientId(client_id: string): { tenant: string | null; product: string | null } | null {
|
||||||
client_id: string
|
|
||||||
): { tenant: string | null; product: string | null } | null {
|
|
||||||
try {
|
try {
|
||||||
const sp = new URLSearchParams(client_id);
|
const sp = new URLSearchParams(client_id);
|
||||||
const tenant = sp.get('tenant');
|
const tenant = sp.get('tenant');
|
||||||
|
@ -54,9 +52,7 @@ export class OAuthController implements IOAuthController {
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async authorize(
|
public async authorize(body: OAuthReqBody): Promise<{ redirect_url: string }> {
|
||||||
body: OAuthReqBody
|
|
||||||
): Promise<{ redirect_url: string }> {
|
|
||||||
const {
|
const {
|
||||||
response_type = 'code',
|
response_type = 'code',
|
||||||
client_id,
|
client_id,
|
||||||
|
@ -75,10 +71,7 @@ export class OAuthController implements IOAuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state) {
|
if (!state) {
|
||||||
throw new JacksonError(
|
throw new JacksonError('Please specify a state to safeguard against XSRF attacks.', 400);
|
||||||
'Please specify a state to safeguard against XSRF attacks.',
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let samlConfig;
|
let samlConfig;
|
||||||
|
@ -95,12 +88,7 @@ export class OAuthController implements IOAuthController {
|
||||||
|
|
||||||
// TODO: Support multiple matches
|
// TODO: Support multiple matches
|
||||||
samlConfig = samlConfigs[0];
|
samlConfig = samlConfigs[0];
|
||||||
} else if (
|
} else if (client_id && client_id !== '' && client_id !== 'undefined' && client_id !== 'null') {
|
||||||
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)
|
// 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);
|
const sp = getEncodedClientId(client_id);
|
||||||
if (sp?.tenant) {
|
if (sp?.tenant) {
|
||||||
|
@ -119,10 +107,7 @@ export class OAuthController implements IOAuthController {
|
||||||
samlConfig = await this.configStore.get(client_id);
|
samlConfig = await this.configStore.get(client_id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new JacksonError(
|
throw new JacksonError('You need to specify client_id or tenant & product', 403);
|
||||||
'You need to specify client_id or tenant & product',
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!samlConfig) {
|
if (!samlConfig) {
|
||||||
|
@ -150,20 +135,15 @@ export class OAuthController implements IOAuthController {
|
||||||
code_challenge_method,
|
code_challenge_method,
|
||||||
});
|
});
|
||||||
|
|
||||||
const redirectUrl = redirect.success(
|
const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
|
||||||
samlConfig.idpMetadata.sso.redirectUrl,
|
RelayState: relayStatePrefix + sessionId,
|
||||||
{
|
SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
|
||||||
RelayState: relayStatePrefix + sessionId,
|
});
|
||||||
SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return { redirect_url: redirectUrl };
|
return { redirect_url: redirectUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async samlResponse(
|
public async samlResponse(body: SAMLResponsePayload): Promise<{ redirect_url: string }> {
|
||||||
body: SAMLResponsePayload
|
|
||||||
): Promise<{ redirect_url: string }> {
|
|
||||||
const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
|
const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
|
||||||
|
|
||||||
let RelayState = body.RelayState || '';
|
let RelayState = body.RelayState || '';
|
||||||
|
@ -204,10 +184,7 @@ export class OAuthController implements IOAuthController {
|
||||||
if (RelayState !== '') {
|
if (RelayState !== '') {
|
||||||
session = await this.sessionStore.get(RelayState);
|
session = await this.sessionStore.get(RelayState);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new JacksonError(
|
throw new JacksonError('Unable to validate state from the origin request.', 403);
|
||||||
'Unable to validate state from the origin request.',
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,11 +214,7 @@ export class OAuthController implements IOAuthController {
|
||||||
|
|
||||||
await this.codeStore.put(code, codeVal);
|
await this.codeStore.put(code, codeVal);
|
||||||
|
|
||||||
if (
|
if (session && session.redirect_uri && !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)) {
|
||||||
session &&
|
|
||||||
session.redirect_uri &&
|
|
||||||
!allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)
|
|
||||||
) {
|
|
||||||
throw new JacksonError('Redirect URL is not allowed.', 403);
|
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> {
|
public async token(body: OAuthTokenReq): Promise<OAuthTokenRes> {
|
||||||
const {
|
const { client_id, client_secret, code_verifier, code, grant_type = 'authorization_code' } = body;
|
||||||
client_id,
|
|
||||||
client_secret,
|
|
||||||
code_verifier,
|
|
||||||
code,
|
|
||||||
grant_type = 'authorization_code',
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (grant_type !== 'authorization_code') {
|
if (grant_type !== 'authorization_code') {
|
||||||
throw new JacksonError('Unsupported grant_type', 400);
|
throw new JacksonError('Unsupported grant_type', 400);
|
||||||
|
@ -289,10 +256,7 @@ export class OAuthController implements IOAuthController {
|
||||||
const sp = getEncodedClientId(client_id);
|
const sp = getEncodedClientId(client_id);
|
||||||
if (!sp) {
|
if (!sp) {
|
||||||
// OAuth flow
|
// OAuth flow
|
||||||
if (
|
if (client_id !== codeVal.clientID || client_secret !== codeVal.clientSecret) {
|
||||||
client_id !== codeVal.clientID ||
|
|
||||||
client_secret !== codeVal.clientSecret
|
|
||||||
) {
|
|
||||||
throw new JacksonError('Invalid client_id or client_secret', 401);
|
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);
|
throw new JacksonError('Invalid code_verifier', 401);
|
||||||
}
|
}
|
||||||
} else if (codeVal && codeVal.session) {
|
} else if (codeVal && codeVal.session) {
|
||||||
throw new JacksonError(
|
throw new JacksonError('Please specify client_secret or code_verifier', 401);
|
||||||
'Please specify client_secret or code_verifier',
|
|
||||||
401
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// store details against a token
|
// store details against a token
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { DatabaseDriver, DatabaseOption, Encrypted, EncryptionKey, Index, Storable } from '../typings';
|
||||||
DatabaseDriver,
|
|
||||||
DatabaseOption,
|
|
||||||
Encrypted,
|
|
||||||
EncryptionKey,
|
|
||||||
Index,
|
|
||||||
Storable,
|
|
||||||
} from '../typings';
|
|
||||||
import * as encrypter from './encrypter';
|
import * as encrypter from './encrypter';
|
||||||
import mem from './mem';
|
import mem from './mem';
|
||||||
import mongo from './mongo';
|
import mongo from './mongo';
|
||||||
|
@ -15,9 +8,7 @@ import store from './store';
|
||||||
|
|
||||||
const decrypt = (res: Encrypted, encryptionKey: EncryptionKey): unknown => {
|
const decrypt = (res: Encrypted, encryptionKey: EncryptionKey): unknown => {
|
||||||
if (res.iv && res.tag) {
|
if (res.iv && res.tag) {
|
||||||
return JSON.parse(
|
return JSON.parse(encrypter.decrypt(res.value, res.iv, res.tag, encryptionKey));
|
||||||
encrypter.decrypt(res.value, res.iv, res.tag, encryptionKey)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.parse(res.value);
|
return JSON.parse(res.value);
|
||||||
|
@ -51,13 +42,7 @@ class DB implements DatabaseDriver {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ttl is in seconds
|
// ttl is in seconds
|
||||||
async put(
|
async put(namespace: string, key: string, val: unknown, ttl = 0, ...indexes: Index[]): Promise<unknown> {
|
||||||
namespace: string,
|
|
||||||
key: string,
|
|
||||||
val: unknown,
|
|
||||||
ttl = 0,
|
|
||||||
...indexes: Index[]
|
|
||||||
): Promise<unknown> {
|
|
||||||
if (ttl > 0 && indexes && indexes.length > 0) {
|
if (ttl > 0 && indexes && indexes.length > 0) {
|
||||||
throw new Error('secondary indexes not allow on a store with ttl');
|
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) => {
|
new: async (options: DatabaseOption) => {
|
||||||
const encryptionKey = options.encryptionKey
|
const encryptionKey = options.encryptionKey ? Buffer.from(options.encryptionKey, 'latin1') : null;
|
||||||
? Buffer.from(options.encryptionKey, 'latin1')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
switch (options.engine) {
|
switch (options.engine) {
|
||||||
case 'redis':
|
case 'redis':
|
|
@ -18,17 +18,8 @@ export const encrypt = (text: string, key: EncryptionKey): Encrypted => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decrypt = (
|
export const decrypt = (ciphertext: string, iv: string, tag: string, key: EncryptionKey): string => {
|
||||||
ciphertext: string,
|
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(iv, 'base64'));
|
||||||
iv: string,
|
|
||||||
tag: string,
|
|
||||||
key: EncryptionKey
|
|
||||||
): string => {
|
|
||||||
const decipher = crypto.createDecipheriv(
|
|
||||||
ALGO,
|
|
||||||
key,
|
|
||||||
Buffer.from(iv, 'base64')
|
|
||||||
);
|
|
||||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
|
||||||
let cleartext = decipher.update(ciphertext, 'base64', 'utf8');
|
let cleartext = decipher.update(ciphertext, 'base64', 'utf8');
|
|
@ -62,13 +62,7 @@ class Mem implements DatabaseDriver {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(
|
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<any> {
|
||||||
namespace: string,
|
|
||||||
key: string,
|
|
||||||
val: Encrypted,
|
|
||||||
ttl = 0,
|
|
||||||
...indexes: any[]
|
|
||||||
): Promise<any> {
|
|
||||||
const k = dbutils.key(namespace, key);
|
const k = dbutils.key(namespace, key);
|
||||||
|
|
||||||
this.store[k] = val;
|
this.store[k] = val;
|
|
@ -27,10 +27,7 @@ class Mongo implements DatabaseDriver {
|
||||||
this.collection = this.db.collection('jacksonStore');
|
this.collection = this.db.collection('jacksonStore');
|
||||||
|
|
||||||
await this.collection.createIndex({ indexes: 1 });
|
await this.collection.createIndex({ indexes: 1 });
|
||||||
await this.collection.createIndex(
|
await this.collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 1 });
|
||||||
{ expiresAt: 1 },
|
|
||||||
{ expireAfterSeconds: 1 }
|
|
||||||
);
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
@ -61,13 +58,7 @@ class Mongo implements DatabaseDriver {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(
|
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||||
namespace: string,
|
|
||||||
key: string,
|
|
||||||
val: Encrypted,
|
|
||||||
ttl = 0,
|
|
||||||
...indexes: any[]
|
|
||||||
): Promise<void> {
|
|
||||||
const doc = <Document>{
|
const doc = <Document>{
|
||||||
value: val,
|
value: val,
|
||||||
};
|
};
|
|
@ -20,9 +20,7 @@ class Redis implements DatabaseDriver {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client = redis.createClient(opts);
|
this.client = redis.createClient(opts);
|
||||||
this.client.on('error', (err: any) =>
|
this.client.on('error', (err: any) => console.log('Redis Client Error', err));
|
||||||
console.log('Redis Client Error', err)
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.client.connect();
|
await this.client.connect();
|
||||||
|
|
||||||
|
@ -39,9 +37,7 @@ class Redis implements DatabaseDriver {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||||
const dbKeys = await this.client.sMembers(
|
const dbKeys = await this.client.sMembers(dbutils.keyForIndex(namespace, idx));
|
||||||
dbutils.keyForIndex(namespace, idx)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ret: string[] = [];
|
const ret: string[] = [];
|
||||||
for (const dbKey of dbKeys || []) {
|
for (const dbKey of dbKeys || []) {
|
||||||
|
@ -51,13 +47,7 @@ class Redis implements DatabaseDriver {
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async put(
|
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||||
namespace: string,
|
|
||||||
key: string,
|
|
||||||
val: Encrypted,
|
|
||||||
ttl = 0,
|
|
||||||
...indexes: any[]
|
|
||||||
): Promise<void> {
|
|
||||||
let tx = this.client.multi();
|
let tx = this.client.multi();
|
||||||
const k = dbutils.key(namespace, key);
|
const k = dbutils.key(namespace, key);
|
||||||
|
|
|
@ -2,12 +2,7 @@
|
||||||
|
|
||||||
require('reflect-metadata');
|
require('reflect-metadata');
|
||||||
|
|
||||||
import {
|
import { DatabaseDriver, DatabaseOption, Index, Encrypted } from '../../typings';
|
||||||
DatabaseDriver,
|
|
||||||
DatabaseOption,
|
|
||||||
Index,
|
|
||||||
Encrypted,
|
|
||||||
} from '../../typings';
|
|
||||||
import { Connection, createConnection } from 'typeorm';
|
import { Connection, createConnection } from 'typeorm';
|
||||||
import * as dbutils from '../utils';
|
import * as dbutils from '../utils';
|
||||||
|
|
|
@ -28,13 +28,7 @@ class Store implements Storable {
|
||||||
return idx;
|
return idx;
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.db.put(
|
return await this.db.put(this.namespace, dbutils.keyDigest(key), val, this.ttl, ...indexes);
|
||||||
this.namespace,
|
|
||||||
dbutils.keyDigest(key),
|
|
||||||
val,
|
|
||||||
this.ttl,
|
|
||||||
...indexes
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(key: string): Promise<any> {
|
async delete(key: string): Promise<any> {
|
|
@ -23,8 +23,7 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
|
||||||
|
|
||||||
newOpts.db = newOpts.db || {};
|
newOpts.db = newOpts.db || {};
|
||||||
newOpts.db.engine = newOpts.db.engine || 'sql';
|
newOpts.db.engine = newOpts.db.engine || 'sql';
|
||||||
newOpts.db.url =
|
newOpts.db.url = newOpts.db.url || 'postgresql://postgres:postgres@localhost:5432/postgres';
|
||||||
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.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.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
|
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) {
|
for (const config of configs) {
|
||||||
await apiController.config(config);
|
await apiController.config(config);
|
||||||
|
|
||||||
console.log(
|
console.log(`loaded config for tenant "${config.tenant}" and product "${config.product}"`);
|
||||||
`loaded config for tenant "${config.tenant}" and product "${config.product}"`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const type =
|
const type = opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
|
||||||
opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
|
|
||||||
|
|
||||||
console.log(`Using engine: ${opts.db.engine}.${type}`);
|
console.log(`Using engine: ${opts.db.engine}.${type}`);
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
const mapping = [
|
const mapping = [
|
||||||
{
|
{
|
||||||
attribute: 'id',
|
attribute: 'id',
|
||||||
schema:
|
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
|
||||||
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attribute: 'email',
|
attribute: 'email',
|
||||||
schema:
|
schema: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||||
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attribute: 'firstName',
|
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 generate = async () => {
|
||||||
const keys = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
const keys = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
||||||
|
|
||||||
const extensions: x509.Extension[] = [
|
const extensions: x509.Extension[] = [new x509.BasicConstraintsExtension(false, undefined, true)];
|
||||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
|
||||||
];
|
|
||||||
|
|
||||||
extensions.push(
|
extensions.push(new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true));
|
||||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true)
|
|
||||||
);
|
|
||||||
if (keys.publicKey) {
|
if (keys.publicKey) {
|
||||||
extensions.push(
|
extensions.push(await x509.SubjectKeyIdentifierExtension.create(keys.publicKey));
|
||||||
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
@ -14,13 +14,7 @@ export interface OAuth {
|
||||||
|
|
||||||
export interface IAPIController {
|
export interface IAPIController {
|
||||||
config(body: IdPConfig): Promise<OAuth>;
|
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: {
|
deleteConfig(body: {
|
||||||
clientID?: string;
|
clientID?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
|
@ -81,13 +75,7 @@ export interface Index {
|
||||||
|
|
||||||
export interface DatabaseDriver {
|
export interface DatabaseDriver {
|
||||||
get(namespace: string, key: string): Promise<any>;
|
get(namespace: string, key: string): Promise<any>;
|
||||||
put(
|
put(namespace: string, key: string, val: any, ttl: number, ...indexes: Index[]): Promise<any>;
|
||||||
namespace: string,
|
|
||||||
key: string,
|
|
||||||
val: any,
|
|
||||||
ttl: number,
|
|
||||||
...indexes: Index[]
|
|
||||||
): Promise<any>;
|
|
||||||
delete(namespace: string, key: string): Promise<any>;
|
delete(namespace: string, key: string): Promise<any>;
|
||||||
getByIndex(namespace: string, idx: Index): Promise<any>;
|
getByIndex(namespace: string, idx: Index): Promise<any>;
|
||||||
}
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import tap from 'tap';
|
import tap from 'tap';
|
||||||
import * as dbutils from '../db/utils';
|
import * as dbutils from '../src/db/utils';
|
||||||
import controllers from '../index';
|
import controllers from '../src/index';
|
||||||
import readConfig from '../read-config';
|
import readConfig from '../src/read-config';
|
||||||
import { IdPConfig, JacksonOption } from '../typings';
|
import { IdPConfig, JacksonOption } from '../src/typings';
|
||||||
|
|
||||||
let apiController;
|
let apiController;
|
||||||
|
|
||||||
|
@ -182,10 +182,7 @@ tap.test('controller/api', async (t) => {
|
||||||
await apiController.getConfig({});
|
await apiController.getConfig({});
|
||||||
t.fail('Expecting Error.');
|
t.fail('Expecting Error.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
t.match(
|
t.match(err.message, 'Please provide `clientID` or `tenant` and `product`.');
|
||||||
err.message,
|
|
||||||
'Please provide `clientID` or `tenant` and `product`.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid clientID
|
// Invalid clientID
|
||||||
|
@ -239,10 +236,7 @@ tap.test('controller/api', async (t) => {
|
||||||
await apiController.deleteConfig({});
|
await apiController.deleteConfig({});
|
||||||
t.fail('Expecting Error.');
|
t.fail('Expecting Error.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
t.match(
|
t.match(err.message, 'Please provide `clientID` and `clientSecret` or `tenant` and `product`.');
|
||||||
err.message,
|
|
||||||
'Please provide `clientID` and `clientSecret` or `tenant` and `product`.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid clientID or clientSecret
|
// Invalid clientID or clientSecret
|
|
@ -1,11 +1,6 @@
|
||||||
import {
|
import { DatabaseEngine, DatabaseOption, EncryptionKey, Storable } from '../src/typings';
|
||||||
DatabaseEngine,
|
|
||||||
DatabaseOption,
|
|
||||||
EncryptionKey,
|
|
||||||
Storable,
|
|
||||||
} from '../typings';
|
|
||||||
import tap from 'tap';
|
import tap from 'tap';
|
||||||
import DB from '../db/db';
|
import DB from '../src/db/db';
|
||||||
|
|
||||||
const encryptionKey: EncryptionKey = '3yGrTcnKPBqqHoH3zZMAU6nt4bmIYb2q';
|
const encryptionKey: EncryptionKey = '3yGrTcnKPBqqHoH3zZMAU6nt4bmIYb2q';
|
||||||
|
|
||||||
|
@ -281,9 +276,7 @@ tap.test('dbs', ({ end }) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, (2 * ttl + 0.5) * 1000));
|
||||||
setTimeout(resolve, (2 * ttl + 0.5) * 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ret1 = await ttlStore.get(record1.id);
|
const ret1 = await ttlStore.get(record1.id);
|
||||||
const ret2 = await ttlStore.get(record2.id);
|
const ret2 = await ttlStore.get(record2.id);
|
|
@ -8,12 +8,12 @@ import {
|
||||||
OAuthReqBody,
|
OAuthReqBody,
|
||||||
OAuthTokenReq,
|
OAuthTokenReq,
|
||||||
SAMLResponsePayload,
|
SAMLResponsePayload,
|
||||||
} from '../typings';
|
} from '../src/typings';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import tap from 'tap';
|
import tap from 'tap';
|
||||||
import { JacksonError } from '../controller/error';
|
import { JacksonError } from '../src/controller/error';
|
||||||
import readConfig from '../read-config';
|
import readConfig from '../src/read-config';
|
||||||
import saml from '../saml/saml';
|
import saml from '../src/saml/saml';
|
||||||
|
|
||||||
let apiController: IAPIController;
|
let apiController: IAPIController;
|
||||||
let oauthController: IOAuthController;
|
let oauthController: IOAuthController;
|
||||||
|
@ -49,7 +49,7 @@ const addMetadata = async (metadataPath) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
tap.before(async () => {
|
tap.before(async () => {
|
||||||
const controller = await (await import('../index')).default(options);
|
const controller = await (await import('../src/index')).default(options);
|
||||||
|
|
||||||
apiController = controller.apiController;
|
apiController = controller.apiController;
|
||||||
oauthController = controller.oauthController;
|
oauthController = controller.oauthController;
|
||||||
|
@ -73,11 +73,7 @@ tap.test('authorize()', async (t) => {
|
||||||
t.fail('Expecting JacksonError.');
|
t.fail('Expecting JacksonError.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, statusCode } = err as JacksonError;
|
const { message, statusCode } = err as JacksonError;
|
||||||
t.equal(
|
t.equal(message, 'Please specify a redirect URL.', 'got expected error message');
|
||||||
message,
|
|
||||||
'Please specify a redirect URL.',
|
|
||||||
'got expected error message'
|
|
||||||
);
|
|
||||||
t.equal(statusCode, 400, 'got expected status code');
|
t.equal(statusCode, 400, 'got expected status code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,43 +116,32 @@ tap.test('authorize()', async (t) => {
|
||||||
t.fail('Expecting JacksonError.');
|
t.fail('Expecting JacksonError.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, statusCode } = err as JacksonError;
|
const { message, statusCode } = err as JacksonError;
|
||||||
t.equal(
|
t.equal(message, 'SAML configuration not found.', 'got expected error message');
|
||||||
message,
|
|
||||||
'SAML configuration not found.',
|
|
||||||
'got expected error message'
|
|
||||||
);
|
|
||||||
t.equal(statusCode, 403, 'got expected status code');
|
t.equal(statusCode, 403, 'got expected status code');
|
||||||
}
|
}
|
||||||
|
|
||||||
t.end();
|
t.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
t.test(
|
t.test('Should throw an error if `redirect_uri` is not allowed', async (t) => {
|
||||||
'Should throw an error if `redirect_uri` is not allowed',
|
const body = {
|
||||||
async (t) => {
|
redirect_uri: 'https://example.com/',
|
||||||
const body = {
|
state: 'state-123',
|
||||||
redirect_uri: 'https://example.com/',
|
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
||||||
state: 'state-123',
|
};
|
||||||
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await oauthController.authorize(<OAuthReqBody>body);
|
await oauthController.authorize(<OAuthReqBody>body);
|
||||||
|
|
||||||
t.fail('Expecting JacksonError.');
|
t.fail('Expecting JacksonError.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, statusCode } = err as JacksonError;
|
const { message, statusCode } = err as JacksonError;
|
||||||
t.equal(
|
t.equal(message, 'Redirect URL is not allowed.', 'got expected error message');
|
||||||
message,
|
t.equal(statusCode, 403, 'got expected status code');
|
||||||
'Redirect URL is not allowed.',
|
|
||||||
'got expected error message'
|
|
||||||
);
|
|
||||||
t.equal(statusCode, 403, 'got expected status code');
|
|
||||||
}
|
|
||||||
|
|
||||||
t.end();
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
t.test('Should return the Idp SSO URL', async (t) => {
|
t.test('Should return the Idp SSO URL', async (t) => {
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -185,18 +170,11 @@ tap.test('samlResponse()', async (t) => {
|
||||||
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { redirect_url } = await oauthController.authorize(
|
const { redirect_url } = await oauthController.authorize(<OAuthReqBody>authBody);
|
||||||
<OAuthReqBody>authBody
|
|
||||||
);
|
|
||||||
|
|
||||||
const relayState = new URLSearchParams(new URL(redirect_url).search).get(
|
const relayState = new URLSearchParams(new URL(redirect_url).search).get('RelayState');
|
||||||
'RelayState'
|
|
||||||
);
|
|
||||||
|
|
||||||
const rawResponse = await fs.readFile(
|
const rawResponse = await fs.readFile(path.join(__dirname, '/data/saml_response'), 'utf8');
|
||||||
path.join(__dirname, '/data/saml_response'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
t.test('Should throw an error if `RelayState` is missing', async (t) => {
|
t.test('Should throw an error if `RelayState` is missing', async (t) => {
|
||||||
const responseBody: Partial<SAMLResponsePayload> = {
|
const responseBody: Partial<SAMLResponsePayload> = {
|
||||||
|
@ -221,69 +199,57 @@ tap.test('samlResponse()', async (t) => {
|
||||||
t.end();
|
t.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
t.test(
|
t.test('Should return a URL with code and state as query params', async (t) => {
|
||||||
'Should return a URL with code and state as query params',
|
const responseBody = {
|
||||||
async (t) => {
|
SAMLResponse: rawResponse,
|
||||||
const responseBody = {
|
RelayState: relayState,
|
||||||
SAMLResponse: rawResponse,
|
};
|
||||||
RelayState: relayState,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stubValidateAsync = sinon
|
const stubValidateAsync = sinon
|
||||||
.stub(saml, 'validateAsync')
|
.stub(saml, 'validateAsync')
|
||||||
.resolves({ audience: '', claims: {}, issuer: '', sessionIndex: '' });
|
.resolves({ audience: '', claims: {}, issuer: '', sessionIndex: '' });
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(code);
|
const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(code);
|
||||||
|
|
||||||
const response = await oauthController.samlResponse(
|
const response = await oauthController.samlResponse(<SAMLResponsePayload>responseBody);
|
||||||
<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(stubValidateAsync.calledOnce, 'validateAsync called once');
|
||||||
t.ok(stubRandomBytes.calledOnce, 'randomBytes called once');
|
t.ok(stubRandomBytes.calledOnce, 'randomBytes called once');
|
||||||
t.ok('redirect_url' in response, 'response contains redirect_url');
|
t.ok('redirect_url' in response, 'response contains redirect_url');
|
||||||
t.ok(params.has('code'), 'query string includes code');
|
t.ok(params.has('code'), 'query string includes code');
|
||||||
t.ok(params.has('state'), 'query string includes state');
|
t.ok(params.has('state'), 'query string includes state');
|
||||||
t.match(params.get('state'), authBody.state, 'state value is valid');
|
t.match(params.get('state'), authBody.state, 'state value is valid');
|
||||||
|
|
||||||
stubRandomBytes.restore();
|
stubRandomBytes.restore();
|
||||||
stubValidateAsync.restore();
|
stubValidateAsync.restore();
|
||||||
|
|
||||||
t.end();
|
t.end();
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
t.end();
|
t.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('token()', (t) => {
|
tap.test('token()', (t) => {
|
||||||
t.test(
|
t.test('Should throw an error if `grant_type` is not `authorization_code`', async (t) => {
|
||||||
'Should throw an error if `grant_type` is not `authorization_code`',
|
const body = {
|
||||||
async (t) => {
|
grant_type: 'authorization_code_1',
|
||||||
const body = {
|
};
|
||||||
grant_type: 'authorization_code_1',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await oauthController.token(<OAuthTokenReq>body);
|
await oauthController.token(<OAuthTokenReq>body);
|
||||||
|
|
||||||
t.fail('Expecting JacksonError.');
|
t.fail('Expecting JacksonError.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, statusCode } = err as JacksonError;
|
const { message, statusCode } = err as JacksonError;
|
||||||
t.equal(
|
t.equal(message, 'Unsupported grant_type', 'got expected error message');
|
||||||
message,
|
t.equal(statusCode, 400, 'got expected status code');
|
||||||
'Unsupported grant_type',
|
|
||||||
'got expected error message'
|
|
||||||
);
|
|
||||||
t.equal(statusCode, 400, 'got expected status code');
|
|
||||||
}
|
|
||||||
|
|
||||||
t.end();
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
t.end();
|
||||||
|
});
|
||||||
|
|
||||||
t.test('Should throw an error if `code` is missing', async (t) => {
|
t.test('Should throw an error if `code` is missing', async (t) => {
|
||||||
const body = {
|
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",
|
"name": "jackson",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"license": "Apache 2.0",
|
"private": true,
|
||||||
"description": "SAML 2.0 service",
|
"description": "SAML 2.0 service (next.js)",
|
||||||
"main": "dist/index.js",
|
"keywords": [
|
||||||
"types": "dist/index.d.ts",
|
"SAML 2.0"
|
||||||
"engines": {
|
],
|
||||||
"node": ">=14.x"
|
|
||||||
},
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/boxyhq/jackson.git"
|
"url": "https://github.com/boxyhq/jackson.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"license": "Apache 2.0",
|
||||||
"SAML 2.0"
|
|
||||||
],
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "next build",
|
||||||
"prepublishOnly": "npm run build",
|
"dev": "cross-env IDP_ENABLED=true next dev -p 5000",
|
||||||
"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 ",
|
|
||||||
"dev-dbs": "docker-compose -f ./_dev/docker-compose.yml up -d",
|
"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",
|
"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": {
|
"husky": {
|
||||||
"coverage-map": "map.js",
|
"hooks": {
|
||||||
"branches": 50,
|
"pre-commit": "lint-staged"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts}": "eslint --cache --fix",
|
"*.{js,ts}": "eslint --cache --fix",
|
||||||
"*.{js,ts,css,md}": "prettier --write"
|
"*.{js,ts,css,md}": "prettier --write"
|
||||||
},
|
},
|
||||||
"files": [
|
"dependencies": {
|
||||||
"dist",
|
"@boxyhq/saml-jackson": "file:./npm",
|
||||||
"Dockerfile"
|
"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": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"module": "CommonJS",
|
"skipLibCheck": true,
|
||||||
"target": "es6", //same as es2015
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitThis": false,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"declaration": true,
|
"module": "esnext",
|
||||||
"noEmitOnError": false,
|
"moduleResolution": "node",
|
||||||
"noUnusedParameters": true,
|
"resolveJsonModule": true,
|
||||||
"removeComments": false,
|
"isolatedModules": true,
|
||||||
"strictNullChecks": true,
|
"jsx": "preserve",
|
||||||
"allowSyntheticDefaultImports": true,
|
"incremental": true,
|
||||||
"experimentalDecorators": true
|
"experimentalDecorators": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["components/*"],
|
||||||
|
"@lib/*": ["lib/*"],
|
||||||
|
"@styles/*": ["styles/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*"],
|
"include": ["next-env.d.ts", "types/*.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules"],
|
"exclude": ["node_modules"]
|
||||||
"ts-node": {
|
|
||||||
"files": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue