[feat] Init code

This commit is contained in:
JasonWu 2021-08-31 18:24:42 +08:00
commit b1e9c5e62a
57 changed files with 17957 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules/
yarn-error.log
.env
deployment/
# vim swap file
*.swp
credential.json
coverage/
fcm_credential.json

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
public/
node_modules/
*.d.ts

9
.eslintrc Normal file
View File

@ -0,0 +1,9 @@
{
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {
"no-console": [1, { "allow": ["info", "warn", "error", "table"] }],
"no-underscore-dangle": ["error", { "allow": ["_id", "_type", "_index", "_source", "super_", "_score", "_modelName"] }]
}
}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
yarn-error.log
.env
# vim swap file
*.swp
credential.json
coverage/
fcm_credential.json

92
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,92 @@
include:
- template: Security/SAST.gitlab-ci.yml
sast:
stage: build
nodejs-scan-sast:
rules:
- if: $SAST_DISABLED
when: never
- if: $CI_COMMIT_BRANCH != "develop"
when: never
- if: $CI_COMMIT_BRANCH &&
$SAST_DEFAULT_ANALYZERS =~ /nodejs-scan/
exists:
- 'package.json'
eslint-sast:
rules:
- if: $SAST_DISABLED
when: never
- if: $CI_COMMIT_BRANCH != "develop"
when: never
- if: $CI_COMMIT_BRANCH &&
$SAST_DEFAULT_ANALYZERS =~ /eslint/
exists:
- '**/*.html'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
stages:
- build
- deploy
dev-build-job:
stage: build
only:
- develop
- merge_requests
image: docker:stable
variables:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: demo_server
services:
- name: registry.lawsnote.com/postgres:13-pgroonga
alias: postgres
- name: redis:5-alpine
alias: redis
script:
# 用 DATE-CI_COMMIT_SHA 當做 docker image 的 tag
- DOCKER_IMAGE_TAG=$(date +%Y%m%d%H%M%S)-${CI_COMMIT_SHA:0:8}
- DOCKER_BUILDKIT=1 docker build
--ssh default="$SSH_PRIVATE_KEY"
--build-arg CI_COMMIT_SHA="$CI_COMMIT_SHA"
-t registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG .
# 使用 build 好的 image 測試
- docker run
--rm
-e DB_HOST=$POSTGRES_PORT_5432_TCP_ADDR
-e DB_USER=postgres
-e DB_PASSWORD=password
-e DB_NAME=demo_server
-e REDIS_HOST=$REDIS_PORT_6379_TCP_ADDR
-e SMS_VENDER=empty
-e NODE_ENV=test
registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG yarn test-with-db
# push docker image
- docker push registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG
# tag latest
- docker tag registry.lawsnote.com/professorx-dev:$DOCKER_IMAGE_TAG registry.lawsnote.com/professorx-dev:latest
- docker push registry.lawsnote.com/professorx-dev:latest
# delete local image
- docker rmi registry.lawsnote.com/professorx-dev:latest
# 執行 Galactus 來刪除不必要的 image只保留 10 個舊版
- docker run --rm --env TARGET_IMAGE=professorx-dev --env KEEP_COUNT=10 --env FORCE=1 registry.lawsnote.com/galactus:latest
tags:
- docker
dev-deploy-job:
stage: deploy
only:
- develop
script:
- docker pull registry.lawsnote.com/professorx-dev:latest
- if [ "$(docker inspect -f '{{.State.Running}}' professorx-dev 2> /dev/null)" == "true" ]; then docker rm -f -v professorx-dev; fi
- docker run --detach --restart always --log-driver=json-file --log-opt max-size=16m --log-opt max-file=2 --publish 30041:10230 --name professorx-dev --env-file "$DEV_SERVICE_ENV" registry.lawsnote.com/professorx-dev:latest
tags:
- office

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:experimental
FROM node:14.14.0 as deps
USER root
WORKDIR /data
COPY package.json yarn.lock ./
#RUN apk add --no-cache git openssh-client
RUN mkdir -p /root/.ssh
RUN echo "Host git.lawsnote.com\n\tStrictHostKeyChecking no\n\tUser git\n\tHostname git.lawsnote.com\n\tPort 2222\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config
# SSH key is set in docker build argument, ex: docker build --ssh default="$SSH_PRIVATE_KEY"
RUN --mount=type=ssh yarn install --frozen-lockfile
FROM node:14.14.0-slim
WORKDIR /data
ARG CI_COMMIT_SHA
COPY . .
COPY --from=deps /data/node_modules ./node_modules/
#RUN apk add --no-cache ffmpeg \
# && chown -R node:node .
RUN chown -R node:node . \
&& sed -i "s/__CI_COMMIT_SHA__/$CI_COMMIT_SHA/" ./public/html/version.html
USER node
CMD ["yarn", "start"]

53
README.md Normal file
View File

@ -0,0 +1,53 @@
# Demo Server
KeyCloak 測試 Server
## 設定環境變數
```
# 伺服器完整網址
SERVER_URL
# 伺服器監聽埠
SERVER_PORT
# JWT 簽名 Secret
SERVER_JWT_SECRET
# Redis 位址
REDIS_HOST
# Redis Port
REDIS_PORT
# Redis 密碼,沒有則留空
REDIS_PASSWORD
# Redis 鍵值前綴
REDIS_PREFIX
# Redis 連線的資料庫號碼
REDIS_DB
# PostgreSQL 資料庫位址
DB_HOST
# PostgreSQL 資料庫連接埠
DB_PORT
# PostgreSQL 資料庫使用者
DB_USER
# PostgreSQL 資料庫密碼
DB_PASSWORD
# PostgreSQL 資料庫名稱
DB_NAME
# PostgreSQL 資料庫連接池最大連線數
DB_POOL_MAX
# PostgreSQL 資料庫連接池閒置連線數
DB_POOL_MIN
```

114
bin/db-migrate.js Normal file
View File

@ -0,0 +1,114 @@
const pg = require('pg');
const fs = require('fs');
const path = require('path');
const config = require('src/config/index.js');
// schema file name format ######_name.sql
const schemaDir = path.resolve(__dirname, '..', 'schemas');
const db = new pg.Client({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
database: config.database.dbname,
});
(async () => {
await db.connect();
await db.query(`select now();`);
let version = -1;
// check migrate record table exists
const checkTable = await db.query(
`
select exists(
select 1
from "information_schema"."tables"
where
"table_schema" = $1
and "table_name" = $2
) as exists
`,
['public', 'migrate_log']
);
if (checkTable.rowCount > 0 && checkTable.rows[0].exists === true) {
// version table exists
const maxVersion = await db.query(`select max("version")::integer as version from "public"."migrate_log"`);
if (maxVersion.rowCount > 0 && maxVersion.rows[0] && maxVersion.rows[0].version !== null) version = maxVersion.rows[0].version; // eslint-disable-line
} else {
// create version table
await db.query(`create table "public"."migrate_log" (
"version" integer not null primary key,
"created_time" timestamptz not null default now()
);`);
}
console.info(`Database Now Version: ${version}`);
// read all schema files
const schemaList = await fs.promises.readdir(schemaDir);
/**
* @type {{[x: number]: boolean}}
*/
const checkDuplicate = {};
/**
* @type {{version: number, filename: string}[]}
*/
const versionList = schemaList
.map(file => {
const strs = file.split('_');
const v = parseInt(strs[0], 10);
if (isNaN(version)) throw new Error(`schema filename format error (######_name.sql)`); // eslint-disable-line
if (v in checkDuplicate) throw new Error(`schema file version (${v}) is duplicate`);
checkDuplicate[v] = true;
return { version: v, filename: file };
})
.filter(t => t && t.version > version)
.sort((a, b) => a.version - b.version);
// 沒有需要更新的檔案
if (versionList.length === 0) return;
await db.query('begin');
try {
const vers = [];
// write all schema file
for (const it of versionList) {
vers.push(`(${it.version})`);
console.info(`Write Version: ${it.version}`);
const fileContent = await fs.promises.readFile(path.resolve(schemaDir, it.filename), 'utf-8');
await db.query(fileContent);
}
await db.query(`insert into "public"."migrate_log" ("version") values ${vers.join(',')}`);
await db.query('commit');
} catch (err) {
await db.query('rollback');
throw err;
}
})()
.then(() => {
console.info('Database Migrate Finish');
})
.catch(err => {
console.error('Database Migrate Failed, ', err);
process.exit(1);
})
.finally(() => {
db.end();
});

58
bin/migrate-tool.js Normal file
View File

@ -0,0 +1,58 @@
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { padLeft } = require('src/utils/index.js');
const schemaDir = path.resolve(__dirname, '..', 'schemas');
(async () => {
const args = process.argv.slice(2);
let filename = args[0] || '';
if (args.length === 0) {
// use readline
filename = await new Promise(resolve => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.prompt();
rl.question('schema filename: ', ans => {
resolve(ans.replace(' ', '_'));
rl.close();
});
rl.once('close', resolve);
});
}
if (filename === '') throw new Error('no schema filename');
const schemaFiles = await fs.promises.readdir(schemaDir);
let version = 0;
schemaFiles.forEach(name => {
if (!name.endsWith('.sql')) return;
const strInt = name.split(/_/g)[0];
const v = parseInt(strInt, 10);
if (isNaN(v)) return; // eslint-disable-line
if (v > version) version = v;
});
// 版本要比最後一筆加一
version += 1;
const schemaName = `${padLeft(`${version}`, 6, '0')}_${filename}.sql`;
const schemaText = `-- Created Time ${new Date().toISOString()}`;
await fs.promises.writeFile(path.resolve(schemaDir, schemaName), schemaText, 'utf-8');
console.info(`File: ${path.resolve(schemaDir, schemaName)} Created!`);
})().catch(err => {
console.error(err);
process.exit(1);
});

38
config/index.js Normal file
View File

@ -0,0 +1,38 @@
const { env } = process;
module.exports = {
server: {
url: env.SERVER_URL || 'http://localhost:10230',
cms_api_url: env.SERVER_CMS_API_URL || 'http://localhost:10230',
port: parseInt(env.SERVER_PORT, 10) || 10230,
jwt_secret: env.SERVER_JWT_SECRET || 'testsecret',
jwt_expire: parseInt(env.SERVER_JWT_EXPIRE, 10) || 60 * 60 * 24 * 30, // 30 day
tos_url: env.SERVER_TOS_URL || 'http://localhost:10230',
course_contract_url: env.SERVER_COURSE_CONTRACT_URL || 'http://localhost:10230',
cms_limit_enabled: env.SERVER_CMS_LIMIT_ENABLED !== '0', // 啟用CMS routing 限制
cms_limit_token: env.SERVER_CMS_LIMIT_TOKEN || '',
},
redis: {
host: env.REDIS_HOST || 'localhost',
port: parseInt(env.REDIS_PORT, 10) || 6379,
password: env.REDIS_PASSWORD || '',
prefix: env.REDIS_PREFIX || '',
db: parseInt(env.REDIS_DB, 10) || 0,
},
sso: {
authorized_endpoint: env.SSO_AUTHORIZED_ENDPOINT || '',
token_endpoint: env.SSO_TOKEN_ENDPOINT || '',
logout_endpoint: env.SSO_LOGOUT_ENDPOINT || '',
client_id: env.SSO_CLIENT_ID || '',
client_secret: env.SSO_CLIENT_SECRET || '',
},
database: {
host: env.DB_HOST || 'localhost',
port: parseInt(env.DB_PORT, 10) || 5432,
user: env.DB_USER || 'postgres',
password: env.DB_PASSWORD || '',
dbname: env.DB_NAME || 'professor_x',
pool_max: parseInt(env.DB_POOL_MAX, 10) || 5,
pool_min: parseInt(env.DB_POOL_MIN, 10) || 2,
},
};

9
constants/index.js Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable no-bitwise */
const constants = {
PAGE_SIZE: 20,
OPENID_EXPIRE: 300, // 5min
INTERNAL_REGULATION_CACHE_TTL: 1800, // 30min
REPORT_CACHE_TTL: 600, // 10 min
};
module.exports = constants;

View File

@ -0,0 +1,26 @@
const { resp } = require('src/utils/response/index.js');
const redis = require('src/utils/redis.js');
const sso = require('src/utils/sso/index.js');
const { OPENID_EXPIRE } = require('src/constants/index.js');
const uuid = require('uuid');
const url = require('url');
const controller = {};
module.exports = controller;
controller.loginSSO = () => async ctx => {
const { back_url: backURL } = ctx.query;
const state = uuid.v4();
const authURL = sso.getAuthURL(state);
// store back url to cache
const cacheKey = redis.Key.ssoLoginCache(state);
await redis.set(cacheKey, JSON.stringify({ back_url: backURL }), 'EX', OPENID_EXPIRE);
const u = new url.URL(authURL);
ctx.resp(resp.Success, { url: u.toString() });
};

113
controllers/common/index.js Normal file
View File

@ -0,0 +1,113 @@
/* eslint-disable no-bitwise */
const debug = require('debug')('ctrl:common');
const util = require('util');
const joi = require('joi');
const response = require('src/utils/response/index.js');
const { copyObject, toNumber } = require('src/utils/index.js');
const { Success, InternalError, DataFormat } = response.resp;
const controller = {};
module.exports = controller;
/**
* api reponse function
* @param {import('src/utils/response/index.js').respObject} resp
* @param {string|Object} body
*/
function responseFunc(resp, body) {
/** @type {import('src/utils/response/index.js').respObject} */
const copy = copyObject(response.checkStruct(resp) ? resp : Success);
if (typeof body === 'string') copy.object.message = body;
else if (typeof body === 'object') copy.object = body;
// @ts-ignore
if (!('obj' in this)) this.obj = {};
this.obj.status = copy.status;
this.obj.object = copy.object;
}
/**
* api error response function
* @param {import('src/utils/response/index.js').respObject} resp
* @param {import('src/utils/response/index.js').codeMessage=} code
*/
function responseError(resp, code) {
/** @type {import('src/utils/response/index.js').respObject} */
const copy = copyObject(response.checkStruct(resp) ? resp : InternalError);
if (code && typeof code === 'object' && 'message' in code && 'code' in code) {
copy.object = code;
}
const err = new response.APIError(copy.object.message, copy);
throw err;
}
controller.apiHandler = () => async (ctx, next) => {
ctx.obj = {};
ctx.token = {};
ctx.resp = responseFunc.bind(ctx);
ctx.err = responseError;
ctx.getBody = key => (ctx.request.body || {})[key];
ctx.getFile = key => (ctx.request.files || {})[key];
// run next
try {
await next();
} catch (err) {
debug(`Get API Throw Error: ${util.inspect(err, false, null)}`);
// debug(err.stack);
if (!(err instanceof response.APIError)) {
ctx.resp(InternalError);
} else {
ctx.obj = err.object;
}
if (process.env.NODE_ENV !== 'production') {
ctx.obj.object.errorStack = err.stack;
ctx.obj.object.errorMessage = err.message;
}
}
if (Object.keys(ctx.obj).length > 0) {
ctx.status = ctx.obj.status;
ctx.body = ctx.obj.object;
}
};
/**
* data validate middleware
* @param {{query?: any, header?: any, body?: any}} schema body,query and header is joi.Schema
*/
controller.validate = schema => {
if (typeof schema !== 'object') responseError(InternalError);
const v = {};
if ('body' in schema) v.body = joi.isSchema(schema.body) ? schema.body : joi.object(schema.body).unknown();
if ('header' in schema) v.header = joi.isSchema(schema.header) ? schema.header : joi.object(schema.header).unknown();
if ('query' in schema) v.query = joi.isSchema(schema.query) ? schema.query : joi.object(schema.query).unknown();
return async (ctx, next) => {
try {
await joi.object(v).unknown().validateAsync({ query: ctx.query, header: ctx.headers, body: ctx.request.body });
} catch (err) {
debug(`data validate error: ${util.inspect(err, false, null)}`);
responseError(DataFormat);
}
return next();
};
};
controller.getAppVersion = () => async (ctx, next) => {
// appVersion Format x.y.z (major.minor.patch)
const appVersion = ctx.get('x-app-version');
const appBuildNumber = toNumber(ctx.get('x-app-buildnumber'), 0);
const appPlatform = ctx.get('x-app-platform');
Object.assign(ctx.state, { appVersion, appBuildNumber, appPlatform });
return next();
};

28
controllers/index.js Normal file
View File

@ -0,0 +1,28 @@
const controller = {};
module.exports = controller;
controller.healthCheck = async ctx => {
ctx.body = 'ok';
ctx.status = 200;
};
controller.appleAppSiteAssociation = async ctx => {
ctx.status = 200;
ctx.body = {
applinks: {
details: [
{
appID: 'CL3K9D5FDN.com.lawsnote.college.staging',
paths: ['*'],
},
{
appID: 'CL3K9D5FDN.com.lawsnote.college',
paths: ['*'],
},
],
},
webcredentials: {
apps: ['CL3K9D5FDN.com.lawsnote.college.staging', 'CL3K9D5FDN.com.lawsnote.college'],
},
};
};

View File

@ -0,0 +1,73 @@
const debug = require('debug')('ctrl:common');
const util = require('util');
const url = require('url');
const sso = require('src/utils/sso/index.js');
const redis = require('src/utils/redis.js');
const { codeMessage, APIError } = require('src/utils/response/index.js');
const config = require('src/config/index.js');
const { jwt } = require('src/utils/pkgs.js');
const controller = {};
module.exports = controller;
controller.verifyCode = () => async ctx => {
const { code, session_state: sessionState, state } = ctx.query;
// logout flow redirect tot frontend
if (state === 'logout') {
ctx.redirect(config.server.frontend_url);
return;
}
// get back url from redis
const cacheKey = redis.Key.ssoLoginCache(state);
const data = await redis.get(cacheKey);
if (!data) ctx.throw('get login cache fail');
const stateObj = JSON.parse(data);
const { back_url: backURL } = stateObj;
if (!backURL) ctx.throw('cache data missing');
const u = new url.URL(backURL);
try {
const token = await sso.getToken(code, sessionState);
// generate jwt token
const jwtToken = jwt.sign(
{
user_id: `${token}-id`,
sso: true,
},
config.server.jwt_secret,
{
expiresIn: config.server.jwt_expire,
issuer: 'lawsnote',
}
);
u.searchParams.append('success', Buffer.from(JSON.stringify({ token: jwtToken })).toString('base64'));
try {
await redis.del(cacheKey);
} catch (err) {
debug(`delete cache fail: ${util.inspect(err, false, null)}`);
}
} catch (err) {
debug(`openid verify fail: ${util.inspect(err, false, null)}`);
/** @type {object} */
const errObj = { ...codeMessage.CodeInternalError };
if (err instanceof APIError) {
// @ts-ignore
Object.assign(errObj, err.object.object);
}
errObj.errorStack = err.stack;
errObj.errorMessage = err.message;
u.searchParams.append('error', Buffer.from(JSON.stringify(errObj)).toString('base64'));
}
ctx.redirect(u.toString());
};

3
doc/testScript.txt Normal file
View File

@ -0,0 +1,3 @@
curl -X GET -H "Content-Type: application/x-www-form-urlencoded" \
-H "Authorization: Bearer " \
https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/userinfo

10
example.env Normal file
View File

@ -0,0 +1,10 @@
DB_PASSWORD=password
DB_NAME=professorx
DEBUG=ctrl*,knex*
DB_MIGRATE=1
SERVER_CMS_LIMIT_ENABLED=0
SSO_AUTHORIZED_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/auth
SSO_TOKEN_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/token
SSO_LOGOUT_ENDPOINT=https://auth.mtfos.xyz/auth/realms/Vision/protocol/openid-connect/logout
SSO_CLIENT_ID=vision
SSO_CLIENT_SECRET=8f6c1fa0-41a0-4218-a02d-3fb28850eb73

13
index.js Normal file
View File

@ -0,0 +1,13 @@
require('dotenv').config();
const config = require('src/config/index.js');
const app = require('./server.js');
async function runServer() {
const server = app.listen(config.server.port, () => {
// @ts-ignore
console.info(`server start on port ${server.address().port}`);
});
}
runServer();

10
jsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2019",
"lib": ["es2020.string"],
"checkJs": true,
"module": "commonjs",
"resolveJsonModule": true
},
"exclude": ["node_modules"]
}

25
model/base.js Normal file
View File

@ -0,0 +1,25 @@
const db = require('src/utils/database.js');
class Base {
constructor() {
this.cols = [];
this.schema = 'public';
this.table = '';
this.db = db;
}
async transaction(trxFunc) {
if (typeof trxFunc !== 'function') throw new Error('transaction function type error');
return this.db.transaction(trxFunc);
}
async checkSchema() {
await this.db
.withSchema(this.schema)
.from(this.table)
.select(...this.cols)
.limit(1);
}
}
module.exports = Base;

14
model/common.js Normal file
View File

@ -0,0 +1,14 @@
/* eslint-disable func-names */
const Base = require('./base.js');
class Common extends Base {
constructor() {
super();
}
async test() {
// nothing
}
}
module.exports = Common;

25
model/public/account.js Normal file
View File

@ -0,0 +1,25 @@
// const debug = require('debug')('models:account');
const Base = require('src/model/base.js');
/**
* @typedef AccountModel
* @property {string} id
* @property {string} phone
* @property {string} password with bcrypt
* @property {string} display_name
* @property {string} secret
* @property {string} created_time
* @property {string} updated_time
*/
class Acconut extends Base {
constructor() {
super();
}
async test() {
}
}
module.exports = Acconut;

16103
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "demo-server",
"version": "1.0.0",
"description": "lawsnote oauth demo server",
"main": "index.js",
"scripts": {
"start": "node index.js",
"migrate": "node bin/db-migrate.js",
"test": "mocha --timeout 5000 --exit test/ && jest --passWithNoTests --runInBand --coverage .",
"test-with-db": "npm run migrate && npm run test",
"new-schema": "node bin/migrate-tool.js",
"postinstall": "node -e \"var s='../',d='node_modules/src',fs=require('fs');fs.exists(d,function(e){e||fs.symlinkSync(s,d,'dir')});\""
},
"keywords": [],
"author": "Jay <admin@trj.tw>",
"license": "MIT",
"dependencies": {
"@google-cloud/storage": "5.4.0",
"@koa/cors": "^3.0.0",
"@koa/router": "^8.0.5",
"@mtfos/swagger-generator": "git+https://github.com/otakukaze/swagger-generator.git#1.2.2",
"axios": "0.21.0",
"debug": "4.2.0",
"dotenv": "^8.2.0",
"got": "^11.8.2",
"ioredis": "4.19.0",
"joi": "17.3.0",
"jsonwebtoken": "8.5.1",
"knex": "0.21.15",
"koa": "^2.11.0",
"koa-body": "^4.1.1",
"koa-logger": "^3.2.1",
"koa-mount": "4.0.0",
"koa-range": "0.3.0",
"koa-static": "5.0.0",
"pg": "8.4.1",
"uuid": "8.3.1"
},
"devDependencies": {
"chai": "4.2.0",
"eslint": "^7.2.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.21.2",
"eslint-plugin-prettier": "^3.1.3",
"jest": "^26.6.0",
"mocha": "^8.2.0",
"prettier": "^2.0.5",
"supertest": "^5.0.0"
},
"nodemonConfig": {
"ignore": [
"credential.json",
"fcm_credential.json"
]
}
}

10
public/html/liveness.html Normal file
View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
live
</body>
</html>

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
readiness
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1,62 @@
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" >
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "/api-docs.json",
dom_id: '#swagger-ui',
deepLinking: true,
docExpansion: 'none',
validatorUrl: null,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
// End Swagger UI call region
window.ui = ui
}
</script>
</body>
</html>

View File

@ -0,0 +1,74 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
</body>
</html>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1);
} else {
qp = location.search.substring(1);
}
arr = qp.split("&")
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';})
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value)
}
) : {}
isValid = qp.state === sentState
if ((
oauth2.auth.schema.get("flow") === "accessCode"||
oauth2.auth.schema.get("flow") === "authorizationCode"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server Passed state wasn't returned from auth server"
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server"
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
window.addEventListener('DOMContentLoaded', function () {
run();
});
</script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

174
public/url.json Normal file
View File

@ -0,0 +1,174 @@
{
"issuer": "https://auth.trj.tw/auth/realms/school",
"authorization_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/auth",
"token_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token",
"introspection_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/token/introspect",
"userinfo_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/userinfo",
"end_session_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/logout",
"jwks_uri": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/certs",
"check_session_iframe": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/login-status-iframe.html",
"grant_types_supported": [
"authorization_code",
"implicit",
"refresh_token",
"password",
"client_credentials"
],
"response_types_supported": [
"code",
"none",
"id_token",
"token",
"id_token token",
"code id_token",
"code token",
"code id_token token"
],
"subject_types_supported": [
"public",
"pairwise"
],
"id_token_signing_alg_values_supported": [
"PS384",
"ES384",
"RS384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512"
],
"id_token_encryption_alg_values_supported": [
"RSA-OAEP",
"RSA-OAEP-256",
"RSA1_5"
],
"id_token_encryption_enc_values_supported": [
"A256GCM",
"A192GCM",
"A128GCM",
"A128CBC-HS256",
"A192CBC-HS384",
"A256CBC-HS512"
],
"userinfo_signing_alg_values_supported": [
"PS384",
"ES384",
"RS384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512",
"none"
],
"request_object_signing_alg_values_supported": [
"PS384",
"ES384",
"RS384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512",
"none"
],
"response_modes_supported": [
"query",
"fragment",
"form_post"
],
"registration_endpoint": "https://auth.trj.tw/auth/realms/school/clients-registrations/openid-connect",
"token_endpoint_auth_methods_supported": [
"private_key_jwt",
"client_secret_basic",
"client_secret_post",
"tls_client_auth",
"client_secret_jwt"
],
"token_endpoint_auth_signing_alg_values_supported": [
"PS384",
"ES384",
"RS384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512"
],
"claims_supported": [
"aud",
"sub",
"iss",
"auth_time",
"name",
"given_name",
"family_name",
"preferred_username",
"email",
"acr"
],
"claim_types_supported": [
"normal"
],
"claims_parameter_supported": true,
"scopes_supported": [
"openid",
"address",
"email",
"microprofile-jwt",
"offline_access",
"phone",
"profile",
"roles",
"web-origins"
],
"request_parameter_supported": true,
"request_uri_parameter_supported": true,
"require_request_uri_registration": true,
"code_challenge_methods_supported": [
"plain",
"S256"
],
"tls_client_certificate_bound_access_tokens": true,
"revocation_endpoint": "https://auth.trj.tw/auth/realms/school/protocol/openid-connect/revoke",
"revocation_endpoint_auth_methods_supported": [
"private_key_jwt",
"client_secret_basic",
"client_secret_post",
"tls_client_auth",
"client_secret_jwt"
],
"revocation_endpoint_auth_signing_alg_values_supported": [
"PS384",
"ES384",
"RS384",
"HS256",
"HS512",
"ES256",
"RS256",
"HS384",
"ES512",
"PS256",
"PS512",
"RS512"
],
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true
}

12
routes/api/index.js Normal file
View File

@ -0,0 +1,12 @@
const Router = require('@koa/router');
const commonCtrl = require('src/controllers/common/index.js');
const v1Router = require('./v1/index.js');
const r = new Router({ prefix: '/api' });
module.exports = r;
// set api handler middleware
r.use(commonCtrl.apiHandler(), commonCtrl.getAppVersion());
r.use(v1Router.routes());

View File

@ -0,0 +1,25 @@
const Router = require('@koa/router');
const joi = require('joi');
const commonCtrl = require('src/controllers/common/index.js');
const accCtrl = require('src/controllers/account/v1/index.js');
const r = new Router({ prefix: '/account' });
module.exports = r;
/**
* get account info
* @swagger
* @route GET /api/v1/account/login/sso
* @group account - account apis
* @param {string} back_url.query.required - back to url
* @returns {RespDefault.model} default -
*/
r.get(
'/login/sso',
commonCtrl.validate({
query: {
back_url: joi.string().required(),
},
}),
accCtrl.loginSSO()
);

8
routes/api/v1/index.js Normal file
View File

@ -0,0 +1,8 @@
const Router = require('@koa/router');
const accountRouter = require('./account/index.js');
const r = new Router({ prefix: '/v1' });
module.exports = r;
r.use(accountRouter.routes());

14
routes/index.js Normal file
View File

@ -0,0 +1,14 @@
const Router = require('@koa/router');
const controller = require('src/controllers/index.js');
const apiRouter = require('./api/index.js');
const oauthRouter = require('./oauth/index.js');
const r = new Router();
module.exports = r;
r.get('/', controller.healthCheck);
r.get(['/apple-app-site-association', '/.well-known/apple-app-site-association'], controller.appleAppSiteAssociation);
r.use(apiRouter.routes());
r.use(oauthRouter.routes());

8
routes/oauth/index.js Normal file
View File

@ -0,0 +1,8 @@
const Router = require('@koa/router');
const oauthCtrl = require('src/controllers/oauth/index.js');
const r = new Router({ prefix: '/oauth' });
r.get('/redirect', oauthCtrl.verifyCode());
module.exports = r;

29
routes/types.js Normal file
View File

@ -0,0 +1,29 @@
// @ts-nocheck
module.exports = {};
/**
* @typedef RespDefault
* @description 預設回傳格式
* @property {string} message
* @property {number} code MessageCode
* @property {string} errorStack api error stack (除了prod以外的環境會有)
* @property {string} errorMessage api error message (除了prod以外的環境會有)
*/
/**
* @typedef Pager
* @description 頁數資訊
* @property {number} page 目前頁數
* @property {number} count 總筆數
* @property {number} total 總頁數
*/
/**
* @typedef Account
* @description API回傳使用者資訊
* @property {string} id 使用者ID
* @property {string} phone 手機
* @property {string} display_name 顯示名稱
* @property {string} created_time 帳號建立時間
* @property {string} updated_time 帳號更新時間
*/

50
schemas/000000_init.sql Normal file
View File

@ -0,0 +1,50 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 11.7
-- Dumped by pg_dump version 11.7
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: log; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA log;
--
-- Name: ltree; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS ltree WITH SCHEMA public;
--
-- Name: EXTENSION ltree; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION ltree IS 'data type for hierarchical tree-like structures';
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';

99
server.js Normal file
View File

@ -0,0 +1,99 @@
const Koa = require('koa');
const path = require('path');
const url = require('url');
const swaggerGenerator = require('@mtfos/swagger-generator');
const { copyObject } = require('src/utils/index.js');
const app = new Koa();
const config = require('src/config/index.js');
// @ts-ignore
app.proxy = true;
// const server = app.listen(config.server.port, () => {
// console.log(`server start on port ${server.address().port}`); // eslint-disable-line
// });
// load middleware module
const koaLogger = require('koa-logger');
const koaCors = require('@koa/cors');
const koaBody = require('koa-body');
const koaMount = require('koa-mount');
const koaStatic = require('koa-static');
const rootRouter = require('src/routes/index.js');
const packageJSON = require('./package.json');
let swaggerDoc = null;
// generate swagger document
(async () => {
const swaggerSpec = {
swaggerDefinition: {
info: {
description: 'KeyCloak OAuth Demo Server',
title: 'KeyCloak',
version: packageJSON.version,
},
host: new url.URL(config.server.url).host,
basePath: '',
produces: ['application/json'],
schemes: ['http', 'https'],
securityDefinitions: {
JWT: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
description: 'Bearer token',
},
},
},
basedir: __dirname,
files: ['./routes/**/*.js'],
};
swaggerDoc = await swaggerGenerator.generateSpec(swaggerSpec);
})().catch(err => {
console.error('[Error] Generate swagger doc failed, ', err);
process.exit(1);
});
// production 不掛上swagger ui
if (process.env.NODE_ENV !== 'production') {
app.use(koaMount('/api-docs/', koaStatic(path.resolve(__dirname, 'public', 'swagger-ui'), { index: 'index.html' })));
rootRouter.get('/api-docs', async c => {
if (!/\/$/.test(c.url)) c.redirect(`${c.url}/`);
});
}
// set swagger doc route
rootRouter.get('/api-docs.json', async c => {
c.type = 'application/json';
const spec = copyObject(swaggerDoc || {});
if (c.protocol === 'https') {
spec.schemes = ['https'];
}
c.body = spec;
});
app.use(koaLogger());
app.use(
koaCors({
credentials: true,
// allow all origin
origin: ctx => ctx.get('origin'),
})
);
app.use(
koaBody({
multipart: true,
formidable: {
maxFileSize: 100 * 1024 * 1024, // 100 mb
},
})
);
app.use(koaMount('/', koaStatic(path.resolve(__dirname, 'public', 'html'))));
app.use(rootRouter.allowedMethods());
app.use(rootRouter.routes());
module.exports = app;

19
utils/database.js Normal file
View File

@ -0,0 +1,19 @@
const knex = require('knex');
const config = require('src/config/index.js');
const pool = knex({
client: 'pg',
connection: {
user: config.database.user,
password: config.database.password,
host: config.database.host,
port: config.database.port,
database: config.database.dbname,
},
pool: {
max: config.database.pool_max,
min: config.database.pool_min,
},
});
module.exports = pool;

166
utils/index.js Normal file
View File

@ -0,0 +1,166 @@
/* eslint-disable no-restricted-globals,no-param-reassign,guard-for-in,no-restricted-syntax,no-continue */
const mod = {};
module.exports = mod;
/**
* check value is number
* @param {any} v input value
* @return {boolean}
*/
mod.isNumber = v => !(!isFinite(v) || v === true || v === false || v === null || v === '');
/**
* value to number
* @param {object} v source value
* @param {number=} defVal default value
* @param {number=} min range min
* @param {number=} max range max
* @return {number}
*/
mod.toNumber = (v, defVal, min, max) => {
let defaultVal = defVal;
let inVal = v;
if (!mod.isNumber(defVal)) defVal = 0;
if (typeof defVal === 'string') defaultVal = parseFloat(defVal);
const minVal = !mod.isNumber(min) ? null : typeof min === 'string' ? parseFloat(min) : min; // eslint-disable-line
const maxVal = !mod.isNumber(max) ? null : typeof max === 'string' ? parseFloat(max) : max; // eslint-disable-line
if (!mod.isNumber(v)) return defaultVal;
if (typeof v === 'string') inVal = parseFloat(v);
if (minVal !== null && inVal < minVal) inVal = min;
if (maxVal !== null && inVal > maxVal) inVal = max;
return inVal;
};
/**
* @exports
* @typedef pageObject
* @prop {number} total page number
* @prop {number} page number
* @prop {number} count all count number
* @prop {number} limit query limit item
* @prop {number} offset query offset number
*
* calc page object
* @param {number} argCount all count
* @param {number} argPage page number
* @param {number} argMaxItem per page item show
* @return {pageObject}
*/
mod.calcPage = (argCount, argPage, argMaxItem = 10) => {
const count = mod.toNumber(argCount, 0, 0);
let page = mod.toNumber(argPage, 1, 1);
const maxItem = mod.toNumber(argMaxItem, 10, 1);
let total = Math.ceil(count / maxItem);
if (total < 1) total = 1;
if (page > total) page = total;
let offset = (page - 1) * maxItem;
if (offset > count) offset = count;
const limit = maxItem;
const pageObject = { total, page, count, offset, limit };
for (const key in pageObject) {
pageObject[key] = mod.toNumber(pageObject[key]);
}
return pageObject;
};
/**
* deep copy object
* @param {any} src
* @return {any}
*/
mod.copyObject = src => {
if (typeof src !== 'object') return src;
const isArray = Array.isArray(src);
const copy = isArray ? [] : {};
for (let it in src) {
// @ts-ignore
if (isArray) it = parseInt(it, 10);
if (typeof src[it] !== 'object') copy[it] = src[it];
else copy[it] = mod.copyObject(src[it]);
}
return copy;
};
/**
* @typedef {(string|number)} SelectObjectAlias
* @description pass string and length > 0 rename key to alias, other keep key name
*/
/**
* @exports
* @typedef {{[key: string]: SelectObjectAlias }} SelectObjectParam
*/
/**
* select object keys
* @param {Object<string, any>} obj
* @param {SelectObjectParam} param
*/
mod.selectObject = (obj, param) => {
if (typeof obj !== 'object' || typeof param !== 'object') throw new Error('input arg wrong');
let newObj = {};
for (const key in param) {
const strs = key.split('.');
const alias = param[key];
if (strs.length > 1) {
if (!(strs[0] in obj)) continue;
newObj = { ...newObj, ...mod.selectObject(obj[strs[0]], { [strs.slice(1).join('.')]: alias }) };
}
const toAlias = param[key] && typeof param[key] === 'string';
if (!(key in obj)) continue;
newObj[toAlias ? alias : key] = obj[key];
}
return newObj;
};
/**
* pad string to target length
* @param {any} v source input
* @param {number} len target length
* @param {number} direct pad direct (-1 left, 1 right)
* @param {string} padChar default '0'
* @return {string}
*/
mod.pad = (v, len = 0, direct = -1, padChar = '0') => {
if (v === null || v === undefined) return '';
if (typeof v !== 'string' && !v.toString) return '';
if (direct !== 1 && direct !== -1) return '';
if (typeof v !== 'string') v = v.toString();
if (typeof padChar !== 'string') padChar = '0';
len = mod.toNumber(len, 0, 0);
if (v.length < len) {
if (direct < 0) v = `${padChar}${v}`;
else v = `${v}${padChar}`;
return mod.pad(v, len, direct, padChar);
}
return v;
};
/**
* pad left
* @param {any} v
* @param {number} len
* @param {string} padChar
* @return {string}
*/
mod.padLeft = (v, len = 0, padChar = '0') => mod.pad(v, len, -1, padChar);
/**
* pad right
* @param {any} v
* @param {number} len
* @param {string} padChar
* @return {string}
*/
mod.padRight = (v, len = 0, padChar = '0') => mod.pad(v, len, 1, padChar);

9
utils/pkgs.js Normal file
View File

@ -0,0 +1,9 @@
const path = require('path');
const jwt = require('jsonwebtoken');
const pkg = {};
pkg.path = path;
pkg.jwt = jwt;
module.exports = pkg;

42
utils/redis.js Normal file
View File

@ -0,0 +1,42 @@
const IORedis = require('ioredis');
const config = require('src/config/index.js');
class Redis extends IORedis {
constructor() {
let { prefix } = config.redis;
const { host, port, password, db } = config.redis;
if (prefix && !/:$/.test(prefix)) prefix += ':';
super({
host,
port,
password,
db,
});
this.prefix = prefix;
const self = this;
// key pattern functions
this.Key = {
/**
* SSO 登入暫存
* @param {string} s state
* @return {string}
*/
ssoLoginCache: s => self.getKeyWithPrefix(`sso-login:${s}`),
};
}
/**
* combine key and prefix
* @param {string} s
* @return {string}
*/
getKeyWithPrefix(s) {
if (typeof s !== 'string') throw new Error('input key not a string');
return `${this.prefix}${s}`;
}
}
module.exports = new Redis();

72
utils/response/index.js Normal file
View File

@ -0,0 +1,72 @@
/* eslint-disable no-underscore-dangle */
/**
* @exports
* @typedef {Object} codeMessage
* @prop {number} code
* @prop {string} message
*/
/**
* @exports
* @typedef {Object} respObject
* @prop {number} status
* @prop {codeMessage} object
*/
/**
* create respobject
* @param {number} status
* @param {codeMessage} codeMsg
*/
const mod = {};
module.exports = mod;
mod.respDefault = (status = 200, codeMsg) => ({ status, object: codeMsg });
mod.APIError = class extends Error {
/**
* @param {string} message
* @param {respObject} resp
*/
constructor(message = '', resp) {
super(message);
this._object = resp || {};
}
get object() {
return this._object;
}
};
/**
* check response object struct
* @param {respObject} v
*/
mod.checkStruct = v => {
if (typeof v !== 'object' || v === null || v === undefined) return false;
if (!('status' in v) || !('object' in v)) return false;
if (typeof v.object !== 'object' || !('code' in v.object) || !('message' in v.object)) return false;
return true;
};
mod.codeMessage = {
CodeSuccess: { code: 1000, message: 'success' },
CodeCreated: { code: 1001, message: 'created' },
CodeAccepted: { code: 1002, message: 'accepted' },
CodeDataFormat: { code: 1003, message: 'data format error' },
CodeUnauthorized: { code: 1004, message: 'unauthorized' },
CodeForbidden: { code: 1005, message: 'forbidden' },
CodeNotFound: { code: 1006, message: 'not found' },
CodeInternalError: { code: 1007, message: 'internal error' },
};
mod.resp = {
Success: mod.respDefault(200, mod.codeMessage.CodeSuccess),
Created: mod.respDefault(201, mod.codeMessage.CodeCreated),
Accepted: mod.respDefault(202, mod.codeMessage.CodeAccepted),
DataFormat: mod.respDefault(400, mod.codeMessage.CodeDataFormat),
Unauthorized: mod.respDefault(401, mod.codeMessage.CodeUnauthorized),
Forbidden: mod.respDefault(403, mod.codeMessage.CodeForbidden),
NotFound: mod.respDefault(404, mod.codeMessage.CodeNotFound),
InternalError: mod.respDefault(500, mod.codeMessage.CodeInternalError),
};

130
utils/sso/index.js Normal file
View File

@ -0,0 +1,130 @@
const joi = require('joi');
const url = require('url');
const querystring = require('querystring');
const got = require('got');
const config = require('src/config/index.js');
const { jwt } = require('src/utils/pkgs.js');
const mod = {};
module.exports = mod;
/**
* @return {string}
*/
mod.getAuthURL = state => {
const input = joi
.object({
authorized_endpoint: joi.string().required(),
token_endpoint: joi.string().required(),
client_id: joi.string().required(),
client_secret: joi.string().required(),
state: joi.string().allow('', null).default(''),
})
.unknown()
.validate({ ...config.sso, state });
if (input.error) throw new Error(input.error.message);
/**
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string }}}
*/
const { value } = input;
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
const qs = {
client_id: value.client_id,
scope: 'openid',
response_type: 'code',
redirect_uri: redirectUri.toString(),
};
if (value.state) qs.state = state;
return `${value.authorized_endpoint}?${querystring.stringify(qs)}`;
};
mod.getLogoutURL = () => {
const input = joi
.object({
logout_endpoint: joi.string().required(),
})
.unknown()
.validate({ ...config.sso });
if (input.error) throw new Error(input.error.message);
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
const qs = { state: 'logout', redirect_uri: redirectUri.toString() };
return `${input.value.logout_endpoint}?${querystring.stringify(qs)}`;
};
/**
* @typedef SSOAccount
* @property {string} username
* @property {string} display_name
* @property {string} email
*/
mod.getToken = async (code, state) => {
const input = joi
.object({
authorized_endpoint: joi.string().required(),
token_endpoint: joi.string().required(),
client_id: joi.string().required(),
client_secret: joi.string().required(),
state: joi.string().required(),
code: joi.string().required(),
})
.unknown()
.validate({ ...config.sso, state, code });
if (input.error) throw new Error(input.error.message);
/**
* @type {{value: { authorized_endpoint: string, token_endpoint: string, client_id: string, client_secret: string, state: string, code: string }}}
*/
const { value } = input;
const redirectUri = new url.URL('/oauth/redirect', config.server.url);
const qs = {
client_id: value.client_id,
client_secret: value.client_secret,
redirect_uri: redirectUri.toString(),
code: value.code,
client_session_state: value.state,
grant_type: 'authorization_code',
};
const resp = await got.default.post(value.token_endpoint, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: querystring.stringify(qs),
responseType: 'json',
});
const { body } = resp;
if (!body) throw new Error('resopnse body empty');
const { id_token: idToken, access_token: accessToken } = body;
if (!idToken) throw new Error('get id token fail');
const decoded = jwt.decode(idToken);
if (!decoded || typeof decoded !== 'object') throw new Error('jwt decode fail');
console.log(decoded)
console.log(body)
// @ts-ignore
const { preferred_username: preferredUsername } = decoded;
if (!preferredUsername) throw new Error('id token field missing');
const displayName = `${decoded.family_name ?? ''}${decoded.given_name ?? ''}`;
/** @type {SSOAccount} */
const ssoAccount = {
username: preferredUsername.toLowerCase(),
display_name: displayName ?? preferredUsername,
email: decoded.email ?? '',
};
return ssoAccount;
};