Initial commit

What is version control? LOL
This commit is contained in:
Steven Qiu 2025-06-23 20:43:34 +08:00
commit 814448dbc9
No known key found for this signature in database
GPG Key ID: 8ACE9DCFC76F38B9
26 changed files with 14884 additions and 0 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
PORT="3000"
DB_HOST=
DB_PORT=
DB_USERNAME=
DB_PASSWORD=
DB_NAME=
ISSUER=
BS_SITE_URL=
SHARED_CLIENT_ID=
TOKEN_EXPIRES_IN_1="259200"
TOKEN_EXPIRES_IN_2="604800"
DEVICE_CODE_EXPIRES_IN="600"
GRANT_EXPIRES_IN="25920000"
# STOP HERE! You have completed all necessary editing.
# DO NOT edit the following lines UNLESS you know what you're doing.
DB_CONNECTION_STRING="mysql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"

62
.gitignore vendored Normal file
View File

@ -0,0 +1,62 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Prisma Schema
prisma/schema.prisma
# Signing Key
oauth-private.key

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Run Script: start:debug",
"request": "launch",
"command": "npm run start:debug",
"cwd": "${workspaceFolder}"
}
]
}

21
LICESNE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present Blessing Skin Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# Janus for Blessing Skin
Janus 是 [Blessing Skin Server](https://github.com/bs-community/blessing-skin-server) 的外挂 Yggdrasil Connect 服务端,基于 [NestJS](https://nestjs.com) 框架和 [oidc-provider](https://github.com/panva/node-oidc-provider) 库编写。
由于 Laravel 框架缺乏合适的 OpenID Connect 服务端扩展包,故采取这种外挂 OpenID Connect 服务端的方式为 Blessing Skin Server 实现 Yggdrasil Connect。
Janus 需要与 Blessing Skin Server 使用同一个 MySQL/MariaDB 数据库。不支持 PostgreSQL 和 SQLite 数据库。
## 环境需求
- Node.js >= 22
- 更低版本或许也可以,但未经测试
- Blessing Skin Server >= 6
- 需要安装 [Yggdrasil Connect](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect) 插件,可在插件市场中下载
- 该插件不需要也不可以与原版 Yggdrasil API 插件同时启用,但插件数据可以通用
- 在安装完该插件后,请务必阅读该插件的 [README](https://github.com/bs-community/blessing-skin-plugins/blob/master/plugins/yggdrasil-connect/README.md),了解如何配置该插件
## 部署指南
请查看 [Wiki - 部署指南](https://github.com/bs-community/janus/wiki/%E9%83%A8%E7%BD%B2%E6%8C%87%E5%8D%97)。
Janus **不是** 开箱即用的,需要手动构建。部署 Janus 并不难,但最好有一定的运维经验。
## 版权信息
Copyright 2025-present Blessing Skin Team. All rights reserved. Open source under the MIT license.
_Disclaimer某站产品经理自己写代码的原则就是代码和人有一个能跑就行自然有些代码很粗糙很难看很低效。如果你看着哪里的代码不爽欢迎直接重构并 PR。_

34
eslint.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

12862
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

80
package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "janus",
"version": "1.0.0",
"description": "Standalone Yggdrasil Connect server for Blessing Skin.",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^6.10.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"nestjs-prisma": "^0.25.0",
"oidc-provider": "^9.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/oidc-provider": "^9.1.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.10.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,333 @@
-- CreateTable
CREATE TABLE `code_id_to_uuid` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`code_id` VARCHAR(255) NOT NULL,
`uuid` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `code_id_to_uuid_code_id_unique`(`code_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `jobs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`queue` VARCHAR(255) NOT NULL,
`payload` LONGTEXT NOT NULL,
`attempts` TINYINT UNSIGNED NOT NULL,
`reserved_at` INTEGER UNSIGNED NULL,
`available_at` INTEGER UNSIGNED NOT NULL,
`created_at` INTEGER UNSIGNED NOT NULL,
INDEX `jobs_queue_index`(`queue`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `language_lines` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`group` VARCHAR(255) NOT NULL,
`key` VARCHAR(255) NOT NULL,
`text` TEXT NOT NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
INDEX `language_lines_group_index`(`group`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `migrations` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`migration` VARCHAR(255) NOT NULL,
`batch` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `notifications` (
`id` CHAR(36) NOT NULL,
`type` VARCHAR(255) NOT NULL,
`notifiable_type` VARCHAR(255) NOT NULL,
`notifiable_id` BIGINT UNSIGNED NOT NULL,
`data` TEXT NOT NULL,
`read_at` TIMESTAMP(0) NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
INDEX `notifications_notifiable_type_notifiable_id_index`(`notifiable_type`, `notifiable_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `oauth_access_tokens` (
`id` VARCHAR(100) NOT NULL,
`user_id` BIGINT UNSIGNED NULL,
`client_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(255) NULL,
`scopes` TEXT NULL,
`revoked` BOOLEAN NOT NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
`expires_at` DATETIME(0) NULL,
INDEX `oauth_access_tokens_user_id_index`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `oauth_auth_codes` (
`id` VARCHAR(100) NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`client_id` BIGINT UNSIGNED NOT NULL,
`scopes` TEXT NULL,
`revoked` BOOLEAN NOT NULL,
`expires_at` DATETIME(0) NULL,
INDEX `oauth_auth_codes_user_id_index`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `oauth_clients` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NULL,
`name` VARCHAR(255) NOT NULL,
`secret` VARCHAR(100) NULL,
`provider` VARCHAR(255) NULL,
`redirect` TEXT NOT NULL,
`personal_access_client` BOOLEAN NOT NULL,
`password_client` BOOLEAN NOT NULL,
`revoked` BOOLEAN NOT NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
INDEX `oauth_clients_user_id_index`(`user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `oauth_personal_access_clients` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`client_id` BIGINT UNSIGNED NOT NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `oauth_refresh_tokens` (
`id` VARCHAR(100) NOT NULL,
`access_token_id` VARCHAR(100) NOT NULL,
`revoked` BOOLEAN NOT NULL,
`expires_at` DATETIME(0) NULL,
INDEX `oauth_refresh_tokens_access_token_id_index`(`access_token_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `options` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`option_name` VARCHAR(50) NOT NULL,
`option_value` LONGTEXT NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `players` (
`pid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`uid` INTEGER NOT NULL,
`name` VARCHAR(50) NOT NULL,
`tid_cape` INTEGER NOT NULL DEFAULT 0,
`last_modified` DATETIME(0) NOT NULL,
`tid_skin` INTEGER NOT NULL DEFAULT -1,
PRIMARY KEY (`pid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `reports` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` INTEGER NOT NULL,
`uploader` INTEGER NOT NULL,
`reporter` INTEGER NOT NULL,
`reason` LONGTEXT NOT NULL,
`status` INTEGER NOT NULL,
`report_at` DATETIME(0) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `scopes` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`description` VARCHAR(255) NOT NULL,
UNIQUE INDEX `scopes_name_unique`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `textures` (
`tid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL,
`type` VARCHAR(10) NOT NULL,
`hash` VARCHAR(64) NOT NULL,
`size` INTEGER NOT NULL,
`uploader` INTEGER NOT NULL,
`public` TINYINT NOT NULL,
`upload_at` DATETIME(0) NOT NULL,
`likes` INTEGER UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`tid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `user_closet` (
`user_uid` INTEGER NOT NULL,
`texture_tid` INTEGER NOT NULL,
`item_name` TEXT NULL
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `users` (
`uid` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`email` VARCHAR(100) NOT NULL,
`nickname` VARCHAR(50) NOT NULL DEFAULT '',
`locale` VARCHAR(255) NULL,
`score` INTEGER NOT NULL,
`avatar` INTEGER NOT NULL DEFAULT 0,
`password` VARCHAR(255) NOT NULL,
`ip` VARCHAR(45) NOT NULL,
`is_dark_mode` BOOLEAN NOT NULL DEFAULT false,
`permission` INTEGER NOT NULL DEFAULT 0,
`last_sign_at` DATETIME(0) NOT NULL,
`register_at` DATETIME(0) NOT NULL,
`verified` BOOLEAN NOT NULL DEFAULT false,
`verification_token` VARCHAR(255) NOT NULL DEFAULT '',
`remember_token` VARCHAR(100) NULL,
PRIMARY KEY (`uid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `uuid` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`pid` INTEGER UNSIGNED NULL,
`name` VARCHAR(255) NOT NULL,
`uuid` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NULL,
`updated_at` TIMESTAMP(0) NULL,
UNIQUE INDEX `uuid_pid_unique`(`pid`),
UNIQUE INDEX `uuid_name_unique`(`name`),
UNIQUE INDEX `uuid_uuid_unique`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ygg_log` (
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
`action` VARCHAR(255) NOT NULL,
`user_id` INTEGER NOT NULL,
`player_id` INTEGER NOT NULL,
`parameters` VARCHAR(2048) NOT NULL DEFAULT '',
`ip` VARCHAR(255) NOT NULL DEFAULT '',
`time` DATETIME(0) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_authorization_codes` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_device_codes` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`userCode` VARCHAR(191) NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_refresh_tokens` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_grants` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_grants_id_key`(`id`),
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_interactions` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_sessions` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `code_id_to_uuid` ADD CONSTRAINT `code_id_to_uuid_code_id_foreign` FOREIGN KEY (`code_id`) REFERENCES `oauth_auth_codes`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `uuid` ADD CONSTRAINT `uuid_pid_foreign` FOREIGN KEY (`pid`) REFERENCES `players`(`pid`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@ -0,0 +1,81 @@
-- CreateTable
CREATE TABLE `yggc_authorization_codes` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_authorization_codes_id_key`(`id`),
UNIQUE INDEX `yggc_authorization_codes_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_device_codes` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`userCode` VARCHAR(191) NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_device_codes_id_key`(`id`),
UNIQUE INDEX `yggc_device_codes_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_refresh_tokens` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`consumed` BOOLEAN NOT NULL DEFAULT false,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_refresh_tokens_id_key`(`id`),
UNIQUE INDEX `yggc_refresh_tokens_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_grants` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_grants_id_key`(`id`),
UNIQUE INDEX `yggc_grants_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_interactions` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_interactions_id_key`(`id`),
UNIQUE INDEX `yggc_interactions_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `yggc_sessions` (
`id` VARCHAR(255) NOT NULL,
`payload` JSON NOT NULL,
`uid` VARCHAR(255) NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `yggc_sessions_id_key`(`id`),
UNIQUE INDEX `yggc_sessions_uid_key`(`uid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

View File

@ -0,0 +1,310 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DB_CONNECTION_STRING")
}
model CodeIdToUUID {
id Int @id @default(autoincrement()) @db.UnsignedInt
code_id String @unique(map: "code_id_to_uuid_code_id_unique") @db.VarChar(255)
uuid String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
code PassportAuthCode @relation(fields: [code_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "code_id_to_uuid_code_id_foreign")
@@map("code_id_to_uuid")
}
model PassportAccessToken {
id String @id @db.VarChar(100)
user_id BigInt? @db.UnsignedBigInt
client_id BigInt @db.UnsignedBigInt
name String? @db.VarChar(255)
scopes String? @db.Text
revoked Boolean
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
expires_at DateTime? @db.DateTime(0)
@@index([user_id], map: "oauth_access_tokens_user_id_index")
@@map("oauth_access_tokens")
}
model PassportAuthCode {
id String @id @db.VarChar(100)
user_id BigInt @db.UnsignedBigInt
client_id BigInt @db.UnsignedBigInt
scopes String? @db.Text
revoked Boolean
expires_at DateTime? @db.DateTime(0)
CodeIdToUUID CodeIdToUUID?
@@index([user_id], map: "oauth_auth_codes_user_id_index")
@@map("oauth_auth_codes")
}
model PassportRefreshToken {
id String @id @db.VarChar(100)
access_token_id String @db.VarChar(100)
revoked Boolean
expires_at DateTime? @db.DateTime(0)
@@index([access_token_id], map: "oauth_refresh_tokens_access_token_id_index")
@@map("oauth_refresh_tokens")
}
model Client {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
user_id BigInt? @db.UnsignedBigInt
client_name String @map("name") @db.VarChar(255)
client_secret String? @map("secret") @db.VarChar(100)
provider String? @db.VarChar(255)
redirect String @db.Text
personal_access_client Boolean
password_client Boolean
revoked Boolean
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
@@index([user_id], map: "oauth_clients_user_id_index")
@@map("oauth_clients")
}
model Option {
id Int @id @default(autoincrement()) @db.UnsignedInt
name String @map("option_name") @db.VarChar(50)
value String @map("option_value") @db.LongText
@@map("options")
}
model Player {
pid Int @id @default(autoincrement()) @db.UnsignedInt
uid Int
name String @db.VarChar(50)
tid_cape Int @default(0)
last_modified DateTime @db.DateTime(0)
tid_skin Int @default(-1)
uuid UUID?
@@map("players")
}
model reports {
id Int @id @default(autoincrement()) @db.UnsignedInt
tid Int
uploader Int
reporter Int
reason String @db.LongText
status Int
report_at DateTime @db.DateTime(0)
@@map("reports")
@@ignore
}
model scopes {
id Int @id @default(autoincrement()) @db.UnsignedInt
name String @unique(map: "scopes_name_unique") @db.VarChar(255)
description String @db.VarChar(255)
@@map("scopes")
@@ignore
}
model User {
uid Int @id @default(autoincrement()) @db.UnsignedInt
email String @db.VarChar(100)
nickname String @default("") @db.VarChar(50)
locale String? @db.VarChar(255)
score Int
avatar Int @default(0)
password String @db.VarChar(255)
ip String @db.VarChar(45)
is_dark_mode Boolean @default(false)
permission Int @default(0)
last_sign_at DateTime @db.DateTime(0)
register_at DateTime @db.DateTime(0)
verified Boolean @default(false)
verification_token String @default("") @db.VarChar(255)
remember_token String? @db.VarChar(100)
@@map("users")
}
model UUID {
id Int @id @default(autoincrement()) @db.UnsignedInt
pid Int? @unique(map: "uuid_pid_unique") @db.UnsignedInt
name String @unique(map: "uuid_name_unique") @db.VarChar(255)
uuid String @unique(map: "uuid_uuid_unique") @db.VarChar(255)
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
player Player? @relation(fields: [pid], references: [pid], onDelete: Cascade, onUpdate: NoAction, map: "uuid_pid_foreign")
@@map("uuid")
}
model AuthorizationCode {
id String @id @unique @db.VarChar(255)
payload Json
uid String? @unique @db.VarChar(255)
consumed Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_authorization_codes")
}
model DeviceCode {
id String @id @unique @db.VarChar(255)
payload Json
userCode String?
uid String? @unique @db.VarChar(255)
consumed Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_device_codes")
}
model RefreshToken {
id String @id @unique @db.VarChar(255)
payload Json
uid String? @unique @db.VarChar(255)
consumed Boolean @default(false)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_refresh_tokens")
}
model Grant {
id String @id @unique @db.VarChar(255)
payload Json
uid String? @unique @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_grants")
}
model Interaction {
id String @id @unique @db.VarChar(255)
payload Json
uid String? @unique @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_interactions")
}
model Session {
id String @id @unique @db.VarChar(255)
payload Json
uid String? @unique @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @updatedAt
@@map("yggc_sessions")
}
model jobs {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
queue String @db.VarChar(255)
payload String @db.LongText
attempts Int @db.UnsignedTinyInt
reserved_at Int? @db.UnsignedInt
available_at Int @db.UnsignedInt
created_at Int @db.UnsignedInt
@@index([queue], map: "jobs_queue_index")
@@map("jobs")
@@ignore
}
model language_lines {
id Int @id @default(autoincrement()) @db.UnsignedInt
group String @db.VarChar(255)
key String @db.VarChar(255)
text String @db.Text
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
@@index([group], map: "language_lines_group_index")
@@map("language_lines")
@@ignore
}
model migrations {
id Int @id @default(autoincrement()) @db.UnsignedInt
migration String @db.VarChar(255)
batch Int
@@map("migrations")
@@ignore
}
model notifications {
id String @id @db.Char(36)
type String @db.VarChar(255)
notifiable_type String @db.VarChar(255)
notifiable_id BigInt @db.UnsignedBigInt
data String @db.Text
read_at DateTime? @db.Timestamp(0)
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
@@index([notifiable_type, notifiable_id], map: "notifications_notifiable_type_notifiable_id_index")
@@map("notifications")
@@ignore
}
model ygg_log {
id Int @id @default(autoincrement()) @db.UnsignedInt
action String @db.VarChar(255)
user_id Int
player_id Int
parameters String @default("") @db.VarChar(2048)
ip String @default("") @db.VarChar(255)
time DateTime @db.DateTime(0)
@@map("ygg_log")
@@ignore
}
model oauth_personal_access_clients {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
client_id BigInt @db.UnsignedBigInt
created_at DateTime? @db.Timestamp(0)
updated_at DateTime? @db.Timestamp(0)
@@map("oauth_personal_access_clients")
@@ignore
}
model textures {
tid Int @id @default(autoincrement()) @db.UnsignedInt
name String @db.VarChar(50)
type String @db.VarChar(10)
hash String @db.VarChar(64)
size Int
uploader Int
public Int @db.TinyInt
upload_at DateTime @db.DateTime(0)
likes Int @default(0) @db.UnsignedInt
@@map("textures")
@@ignore
}
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
model user_closet {
user_uid Int
texture_tid Int
item_name String? @db.Text
@@map("user_closet")
@@ignore
}

40
src/app.controller.ts Normal file
View File

@ -0,0 +1,40 @@
import { Controller, All, Req, Res, Get, Redirect } from '@nestjs/common';
import { Request, Response } from "express";
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) { }
@Get("/interaction/:uid")
async interaction(@Req() req: Request, @Res() res: Response): Promise<object | void> {
return this.appService.interaction(req, res);
}
@Get("/interaction/:uid/callback")
async interactionLogin(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.appService.interactionCallback(req, res);
}
@Get("/userinfo")
@Redirect("", 303)
async userinfo(): Promise<object> {
return {
url: this.appService.getSiteUrl() + "/yggc/userinfo",
};
}
/* @Get('/device')
async userCodeVerification(@Req() req: Request, @Res() res: Response, @Query('xsrf') query: string): Promise<void | object> {
if(query != undefined) {
return this.appService.callback(req, res);
} else {
const url = this.appService.getSiteUrl() + "/yggc/device";
res.redirect(HttpStatus.SEE_OTHER, url);
}
} */
@All("/*")
getHello(@Req() req: Request, @Res() res: Response): Promise<void> {
return this.appService.callback(req, res);
}
}

38
src/app.module.ts Normal file
View File

@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CustomPrismaModule } from 'nestjs-prisma';
import { EXTENDED_PRISMA_SERVICE, getExtendedPrismaClient } from './extended-prisma-client';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OIDCProviderService } from './oidc-provider.service';
import { readFileSync } from 'fs';
import { importPKCS8, exportJWK, JWK } from 'jose';
import { validate } from './env-validation';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validate }),
CustomPrismaModule.forRootAsync({
name: EXTENDED_PRISMA_SERVICE,
useFactory: (config: ConfigService) => { // SO FUCKING STUPID, someone refactor this please
return getExtendedPrismaClient(config.get<string>('BS_SITE_URL')!);
},
inject: [ConfigService],
}),
],
controllers: [AppController],
providers: [
AppService,
{
provide: "JWK",
useFactory: async (): Promise<JWK> => {
const pkcs8: string = readFileSync("./oauth-private.key", 'utf8');
const keyImported: CryptoKey = await importPKCS8(pkcs8, 'RS256', { extractable: true });
return exportJWK(keyImported);
},
},
OIDCProviderService
],
})
export class AppModule { }

155
src/app.service.ts Normal file
View File

@ -0,0 +1,155 @@
import { Inject, Injectable } from '@nestjs/common';
import * as oidc from 'oidc-provider';
import { BS_RESOURCE_INDICATOR, OIDCProviderService } from './oidc-provider.service';
import { Request, Response } from "express";
import { CustomPrismaService } from 'nestjs-prisma';
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
import { getDateWithTimezoneOffset } from './helper';
import { CodeIdToUUID, PassportAuthCode, User } from '@prisma/client';
import { YggCScopes } from './blessing.types';
@Injectable()
export class AppService {
readonly callback: (req: Request, res: Response) => Promise<void>;
private readonly provider: oidc.Provider;
private readonly siteUrl: string;
constructor(
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
private readonly oidcProvider: OIDCProviderService,
) {
this.provider = this.oidcProvider.provider;
this.siteUrl = this.oidcProvider.siteUrl;
this.callback = this.provider.callback();
}
async interaction(req: Request, res: Response): Promise<object | void> {
const result = await this.provider.interactionDetails(req, res);
const { params } = result;
const url = `${this.siteUrl}/oauth/authorize?client_id=${params.client_id}&response_type=code&scope=${params.scope}&redirect_uri=${this.siteUrl}/yggc/callback&state=${result.jti}&prompt=consent`
/* if(result.deviceCode) {
return res.json({
url: url
});
} */
return res.redirect(url);
}
async interactionCallback(req: Request, res: Response): Promise<void> {
const { code, error, error_description } = req.query as { code: string; error: string | undefined; error_description: string | undefined; };
const { client_id, scope } = (await this.provider.interactionDetails(req, res)).params as { client_id: string; scope: string; };
if (error) {
return this.provider.interactionFinished(req, res, {
error: error,
error_description: error_description
}, { mergeWithLastSubmission: false });
}
const authCode: PassportAuthCode | null = await this.prisma.client.passportAuthCode.findFirst({
where: {
id: code,
revoked: false,
expires_at: {
gte: getDateWithTimezoneOffset()
}
}
});
if (!authCode) {
return this.provider.interactionFinished(req, res, {
error: "invalid_grant",
error_description: "Invalid or expired code"
});
}
const user: User | null = await this.prisma.client.user.findFirst({
where: {
uid: Number(authCode?.user_id),
verified: true,
permission: {
not: -1
}
}
});
if (!user) {
return this.provider.interactionFinished(req, res, {
error: "invalid_grant",
error_description: "Invalid or expired code"
});
}
const scopesInSession: Set<string> = new Set(scope.split(" "));
const scopesInBSAuth: Set<string> = new Set(scopesInSession);
const scopes = scopesInSession.intersection(scopesInBSAuth);
if (scopes.has(YggCScopes.PROFILE_SELECT)) {
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
where: {
code_id: code
}
});
if (!codeIdToUUID) {
return this.provider.interactionFinished(req, res, {
error: "invalid_grant",
error_description: "Invalid or expired code"
});
}
const uuid = await this.prisma.client.uUID.findFirst({
where: {
uuid: codeIdToUUID.uuid
},
include: {
player: true
}
});
if (!uuid) {
return this.provider.interactionFinished(req, res, {
error: "invalid_grant",
error_description: "Invalid or expired code"
});
}
}
const grant: oidc.Grant = new this.provider.Grant({
clientId: client_id,
accountId: user.uid.toString(),
});
scopes.forEach((scope) => {
grant.addOIDCScope(scope);
grant.addResourceScope(BS_RESOURCE_INDICATOR, scope);
});
grant.jti = code;
await grant.save();
await this.prisma.client.passportAuthCode.update({
where: {
id: code
},
data: {
revoked: true,
}
});
return this.provider.interactionFinished(req, res, {
login: {
accountId: user.uid.toString()
},
consent: {
grantId: code
}
}, { mergeWithLastSubmission: false });
}
getSiteUrl(): string {
return this.siteUrl;
}
}

41
src/blessing.types.ts Normal file
View File

@ -0,0 +1,41 @@
export type YggdrasilProfile = {
id: string;
name: string;
};
export type UserInfo = {
sub: string;
nickname?: string;
email?: string;
email_verified?: boolean;
picture?: string;
selectedProfile?: YggdrasilProfile;
availableProfiles?: YggdrasilProfile[];
};
export enum YggCScopes {
OPENID = 'openid',
PROFILE = 'profile',
EMAIL = 'email',
PROFILE_SELECT = 'Yggdrasil.PlayerProfiles.Select',
PROFILE_READ = 'Yggdrasil.PlayerProfiles.Read',
SERVER_JOIN = 'Yggdrasil.Server.Join'
}
export enum YggCClaims {
NICKNAME = 'nickname',
PICTURE = 'picture',
EMAIL = 'email',
EMAIL_VERIFIED = 'email_verified',
SELECTED_PROFILE = 'selectedProfile',
AVAILABLE_PROFILES = 'availableProfiles'
}
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
export enum RoutesWithoutCORS {
'auth',
'device',
'interaction'
}

89
src/env-validation.ts Normal file
View File

@ -0,0 +1,89 @@
import { plainToInstance } from "class-transformer";
import { IsNotEmpty, IsNumber, IsString, IsUrl, Max, Min, registerDecorator, validateSync, ValidationArguments, ValidationOptions } from "class-validator";
class Env {
@IsNumber()
@Min(0)
@Max(65535)
PORT: number;
@IsString()
@IsNotEmpty()
DB_HOST: string;
@IsNumber()
@Min(0)
@Max(65535)
DB_PORT: number;
@IsString()
@IsNotEmpty()
DB_USERNAME: string;
@IsString()
@IsNotEmpty()
DB_PASSWORD: string;
@IsString()
@IsNotEmpty()
DB_NAME: string;
@IsUrl({ require_tld: false })
@IsSecureUrl()
ISSUER: string;
@IsUrl({ require_tld: false })
@IsSecureUrl()
BS_SITE_URL: string;
@IsString()
SHARED_CLIENT_ID: string;
@IsNumber()
TOKEN_EXPIRES_IN_1: number;
@IsNumber()
TOKEN_EXPIRES_IN_2: number;
@IsNumber()
DEVICE_CODE_EXPIRES_IN: number;
@IsNumber()
GRANT_EXPIRES_IN: number;
}
function IsSecureUrl(property?: string, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isSecureUrl',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property],
validator: {
validate(value: string, args: ValidationArguments) {
const url = new URL(value);
return (url.protocol === 'https:' || url.hostname == 'localhost') && !url.search.length && !url.hash.length && !value.endsWith('/');
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a secure URL (either localhost or HTTPS), not end with a slash, and not contain queries and fragments.`;
}
}
})
};
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(
Env,
config,
{ enableImplicitConversion: true },
);
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}

View File

@ -0,0 +1,53 @@
import { PrismaClient } from '@prisma/client';
export function getExtendedPrismaClient(siteUrl: string) {
const extendedPrismaClient = new PrismaClient().$extends({
result: {
client: {
client_id: {
needs: { id: true },
compute(data: { id: bigint; }) {
return data.id.toString();
}
},
redirect_uris: {
needs: { redirect: true },
compute(data: { redirect: string; }) {
const splitted: string[] = data.redirect.split(',').map((uri: string) => uri.trim());
return splitted;
}
}
}
},
}).$extends({
result: {
client: {
token_endpoint_auth_method: {
needs: { redirect_uris: true },
compute: (data: { redirect_uris: string[] }) => {
if(data.redirect_uris.indexOf(`${siteUrl}/yggc/client/public`) !== -1) {
return 'none';
}
}
}
}
}
}).$extends({
result: {
$allModels: {
payload: {
compute(data: object | undefined) {
if (data != undefined) {
return data.hasOwnProperty('payload') ? data['payload'] : data;
}
},
}
},
}
});
return extendedPrismaClient;
}
export type ExtendedPrismaClient = ReturnType<typeof getExtendedPrismaClient>;
export const EXTENDED_PRISMA_SERVICE = 'EXTENDED_PRISMA_SERVICE';

3
src/helper.ts Normal file
View File

@ -0,0 +1,3 @@
export function getDateWithTimezoneOffset(): Date {
return new Date(Date.now() - new Date().getTimezoneOffset() * 60 * 1000);
}

30
src/main.ts Normal file
View File

@ -0,0 +1,30 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';
import { RoutesWithoutCORS } from './blessing.types';
import { Request } from 'express';
async function bootstrap() {
BigInt.prototype['toJSON'] = function () {
const int = Number.parseInt(this.toString());
return int ?? this.toString();
};
const app = await NestFactory.create<NestExpressApplication>(AppModule);
/*
> The Token Endpoint ... and any other endpoints directly accessed by Clients SHOULD support the use of Cross-Origin Resource Sharing (CORS) ...
> The use of CORS at the Authorization Endpoint is NOT RECOMMENDED as it is redirected to by the client and not directly accessed.
https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
*/
app.enableCors((req: Request, callback) => {
callback(null, {
origin: req.path.split('/', 2)[1] in RoutesWithoutCORS ? false : '*'
}
);
});
app.enable('trust proxy');
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

136
src/oidc-adapter.ts Normal file
View File

@ -0,0 +1,136 @@
import { Adapter, AdapterPayload, AdapterFactory } from 'oidc-provider';
import { ExtendedPrismaClient } from './extended-prisma-client';
import { ConfigService } from '@nestjs/config';
export type ModelType =
"Grant" |
"Session" |
"AccessToken" |
"AuthorizationCode" |
"RefreshToken" |
"ClientCredentials" |
"Client" |
"InitialAccessToken" |
"RegistrationAccessToken" |
"DeviceCode" |
"Interaction" |
"ReplayDetection" |
"BackchannelAuthenticationRequest" |
"PushedAuthorizationRequest";
export class OIDCAdapter implements Adapter {
private readonly model: string;
constructor(private readonly type: ModelType, private readonly prisma: ExtendedPrismaClient, private readonly config: ConfigService) {
this.model = type.charAt(0).toLowerCase() + type.slice(1);
}
public static getAdapterFactory(prisma: ExtendedPrismaClient, config: ConfigService): AdapterFactory {
return (type: ModelType) => {
return new OIDCAdapter(type, prisma, config);
};
}
async upsert(id: string, payload: AdapterPayload): Promise<any> {
const data = {
uid: payload.uid,
payload: payload,
userCode: payload.userCode
};
await this.prisma[this.model].upsert({
where: {
id: id
},
create: {
id: id,
...data
},
update: {
...data
}
});
}
async find(id: string): Promise<AdapterPayload | undefined> {
const data = await this.prisma[this.model].findFirst({
where: {
id: this.type == 'Client' ? parseInt(id) : id
}
});
if (!data) {
return undefined;
}
const accountId = data.payload?.accountId;
if (accountId) {
const user = await this.prisma.user.findFirst({
where: {
uid: parseInt(accountId)
}
});
if (!user) {
return undefined;
}
}
return data.payload;
}
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
const data = await this.prisma.deviceCode.findFirst({
where: {
userCode: userCode
}
});
if (data) {
return data.payload;
}
}
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
const data = await this.prisma[this.model].findFirst({
where: {
uid: uid
}
});
if (!data) {
return undefined;
}
return data.payload;
}
async consume(id: string): Promise<void> {
await this.prisma[this.model].update({
where: {
id: id
},
data: {
consumed: true
}
});
}
async destroy(id: string): Promise<void> {
await this.prisma[this.model].deleteMany({
where: {
id: id
}
});
}
async revokeByGrantId(grantId: string): Promise<undefined | void> {
await this.prisma.grant.deleteMany({
where: {
id: grantId
}
});
}
}

View File

@ -0,0 +1,411 @@
import * as oidc from 'oidc-provider';
import { OIDCAdapter } from './oidc-adapter';
import { EXTENDED_PRISMA_SERVICE, ExtendedPrismaClient } from './extended-prisma-client';
import { JWK } from 'jose';
import { getDateWithTimezoneOffset } from './helper';
import { ConfigService } from '@nestjs/config';
import { UserInfo, YggCClaims, YggCScopes, YggdrasilProfile } from './blessing.types';
import { CodeIdToUUID, PassportAccessToken, Player, UUID } from '@prisma/client';
import { Inject, Injectable } from '@nestjs/common';
import { CustomPrismaService } from 'nestjs-prisma';
export const BS_RESOURCE_INDICATOR: string = "https://github.com/bs-community/blessing-skin-server";
export const ACCESS_TOKEN_NAME: string = "Yggdrasil Connect";
@Injectable()
export class OIDCProviderService {
readonly provider: oidc.Provider;
readonly siteUrl: string;
readonly session: oidc.Session;
constructor(
private readonly config: ConfigService,
@Inject(EXTENDED_PRISMA_SERVICE) private readonly prisma: CustomPrismaService<ExtendedPrismaClient>,
@Inject("JWK") private readonly jwk: JWK,
) {
const siteUrl: string = this.config.get<string>("BS_SITE_URL")!;
const issuer: string = this.config.get<string>("ISSUER")!;
const tokenExpiresIn1: number = this.config.get<number>("TOKEN_EXPIRES_IN_1")!;
const tokenExpiresIn2: number = this.config.get<number>("TOKEN_EXPIRES_IN_2")!;
const deviceCodeExpiresIn: number = this.config.get<number>("DEVICE_CODE_EXPIRES_IN")!;
const grantExpiresIn: number = this.config.get<number>("GRANT_EXPIRES_IN")!;
const sharedClientId: string | undefined = this.config.get<string>("SHARED_CLIENT_ID");
const basePolicy = oidc.interactionPolicy.base();
const loginPrompt = basePolicy.get('login')!;
const consentPrompt = basePolicy.get('consent')!;
const grantPrompt = new oidc.interactionPolicy.Prompt({ name: 'grant', requestable: true }, new oidc.interactionPolicy.Check('grant', 'invalid grant', (ctx) => {
const oidcContext = ctx.oidc;
if (!oidcContext.entities.Grant) {
return oidc.interactionPolicy.Check.REQUEST_PROMPT;
}
return oidc.interactionPolicy.Check.NO_NEED_TO_PROMPT;
}));
const provider = new oidc.Provider(issuer, {
adapter: OIDCAdapter.getAdapterFactory(this.prisma.client, this.config),
jwks: {
keys: [this.jwk]
},
clientAuthMethods: [
'client_secret_post',
'none'
],
responseTypes: ['code', 'id_token', 'code id_token'],
claims: {
[YggCScopes.PROFILE]: [YggCClaims.NICKNAME, YggCClaims.PICTURE],
[YggCScopes.EMAIL]: [YggCClaims.EMAIL, YggCClaims.EMAIL_VERIFIED],
[YggCScopes.PROFILE_SELECT]: [YggCClaims.SELECTED_PROFILE],
[YggCScopes.PROFILE_READ]: [YggCClaims.AVAILABLE_PROFILES]
},
scopes: [
YggCScopes.EMAIL,
YggCScopes.PROFILE,
YggCScopes.PROFILE_SELECT,
YggCScopes.PROFILE_READ,
YggCScopes.SERVER_JOIN,
'offline_access',
'openid'
],
async loadExistingGrant(ctx: oidc.KoaContextWithOIDC) {
const grantId = ctx.oidc.result?.consent?.grantId;
if (grantId) {
return ctx.oidc.provider.Grant.find(grantId);
}
return undefined;
},
findAccount: this.findAccount.bind(this),
conformIdTokenClaims: false,
features: {
deviceFlow: {
enabled: true,
},
dPoP: {
enabled: false
},
devInteractions: { enabled: false },
resourceIndicators: {
enabled: true,
defaultResource(ctx: oidc.KoaContextWithOIDC, client: oidc.Client, oneOf: string[] | undefined) {
return BS_RESOURCE_INDICATOR;
},
getResourceServerInfo(ctx: oidc.KoaContextWithOIDC, resourceIndicator: string, client: oidc.Client) {
return {
scope: Array.from(ctx.oidc.requestParamScopes).join(' '),
audience: BS_RESOURCE_INDICATOR,
accessTokenFormat: 'jwt',
jwt: {
sign: { alg: 'RS256' },
},
};
},
useGrantedResource(ctx: oidc.KoaContextWithOIDC, model) {
return true;
},
},
pushedAuthorizationRequests: {
enabled: false
},
rpInitiatedLogout: {
enabled: false
}
},
formats: {
customizers: {
async jwt(ctx, token, jwt) {
jwt.payload.aud = ctx.oidc.client!.clientId;
return jwt;
},
}
},
interactions: {
policy: [
loginPrompt,
grantPrompt,
consentPrompt
],
url(ctx, interaction) {
const prompt = interaction.prompt;
return `/interaction/${interaction.uid}`;
},
},
async extraTokenClaims(ctx: oidc.KoaContextWithOIDC, token: oidc.AccessToken) {
const oidcContext = ctx.oidc;
const claims: oidc.AccountClaims | undefined = await oidcContext.account?.claims('id_token', token.scope!, {}, []);
const selectedProfile = claims?.selectedProfile as YggdrasilProfile | undefined;
return {
selectedProfile: selectedProfile?.id,
scopes: Array.from(oidcContext.entities.RefreshToken?.scopes ?? token.scopes)
};
},
expiresWithSession(ctx: oidc.KoaContextWithOIDC, token): boolean {
return false;
},
ttl: {
AccessToken: tokenExpiresIn1,
AuthorizationCode: 10 * 60,
DeviceCode: deviceCodeExpiresIn,
Grant: grantExpiresIn,
IdToken: tokenExpiresIn1,
RefreshToken: tokenExpiresIn2,
Session: 15 * 60,
Interaction: 15 * 60,
},
rotateRefreshToken: true,
routes: {
userinfo: '/userinfo'
},
clientDefaults: {
application_type: 'native',
response_types: ['code', 'id_token', 'code id_token'],
grant_types: ['authorization_code', 'implicit', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'],
token_endpoint_auth_method: "client_secret_post"
},
discovery: {
shared_client_id: sharedClientId?.length ? sharedClientId : undefined,
},
});
provider.proxy = true;
provider.on('server_error', (ctx, error) => {
console.log(error);
console.log(error.stack);
});
/*
Access Token JWToidc-provider Access Token
Access Token Laravel Passport
*/
provider.on('access_token.issued', async (token: oidc.AccessToken) => {
const date: Date = getDateWithTimezoneOffset();
const maxTokenCount = parseInt(await this.getBlessingOption("ygg_tokens_limit", "5"));
const tokenIssued: PassportAccessToken[] = await prisma.client.passportAccessToken.findMany({
where: {
client_id: parseInt(token.clientId!),
user_id: parseInt(token.accountId),
name: ACCESS_TOKEN_NAME,
revoked: false,
expires_at: {
gte: date
}
}
});
if (tokenIssued.length >= maxTokenCount) {
tokenIssued.slice(0, tokenIssued.length - maxTokenCount + 1).forEach(async (token) => {
await prisma.client.passportAccessToken.update({
where: {
id: token.id
},
data: {
revoked: true,
}
});
});
}
await prisma.client.passportAccessToken.create({
data: {
id: token.jti,
client_id: parseInt(token.clientId!),
user_id: parseInt(token.accountId),
name: ACCESS_TOKEN_NAME,
scopes: JSON.stringify(token.extra?.scopes),
revoked: false,
created_at: date,
expires_at: new Date(date.getTime() + token.expiration * 1000),
}
});
});
provider.on('refresh_token.consumed', async (rotatedRefreshToken: oidc.RefreshToken) => {
const passportRefreshToken = await prisma.client.passportRefreshToken.findFirst({
where: {
id: rotatedRefreshToken.jti,
revoked: false,
expires_at: {
gte: getDateWithTimezoneOffset()
}
},
select: {
access_token_id: true,
}
});
if (passportRefreshToken) {
await prisma.client.passportAccessToken.update({
where: {
id: passportRefreshToken.access_token_id
},
data: {
revoked: true
}
});
await prisma.client.passportRefreshToken.update({
where: {
id: rotatedRefreshToken.jti
},
data: {
revoked: true
}
});
}
});
/*
Access Token Access Token oidc-provider Access Token
Refresh Token Access Token Access Token
*/
provider.on('access_token.issued', this.saveRefreshTokenToPassport.bind(this));
provider.on('refresh_token.saved', this.saveRefreshTokenToPassport.bind(this));
this.siteUrl = siteUrl;
this.provider = provider;
}
async findAccount(ctx: oidc.KoaContextWithOIDC, id: string, token?: oidc.AuthorizationCode | oidc.AccessToken | oidc.DeviceCode | oidc.RefreshToken): Promise<oidc.Account | undefined> {
const grantId = ctx.oidc.result?.consent?.grantId ?? token?.grantId;
const authCode = await this.prisma.client.passportAuthCode.findFirst({
where: {
id: grantId,
user_id: parseInt(id),
}
});
if (!authCode) {
return undefined;
}
const grant = ctx.oidc.entities.Grant ?? await ctx.oidc.provider.Grant.find(grantId!);
if (!grant) {
return undefined;
}
const user = await this.prisma.client.user.findFirst({
where: {
uid: Number(authCode.user_id),
verified: true,
permission: {
not: -1
}
}
});
if (!user) {
return undefined;
}
const userInfo: UserInfo = {
sub: id,
nickname: user.nickname,
email: user.email,
email_verified: Boolean(user.verified),
picture: `${this.siteUrl}/avatar/user/${user.uid}`,
};
const scopes: string[] | undefined = grant.resources?.[BS_RESOURCE_INDICATOR]?.split(' ');
if (scopes?.includes(YggCScopes.PROFILE_SELECT)) {
const codeIdToUUID: CodeIdToUUID | null = await this.prisma.client.codeIdToUUID.findFirst({
where: {
code_id: grantId
}
});
if (!codeIdToUUID) {
return undefined;
}
const uuid = await this.prisma.client.uUID.findFirst({
where: {
uuid: codeIdToUUID.uuid
},
include: {
player: true
}
});
if (!uuid) {
return undefined;
}
userInfo.selectedProfile = {
id: uuid.uuid,
name: uuid.player!.name
};
}
if (scopes?.includes(YggCScopes.PROFILE_READ)) {
const players: Player[] = await this.prisma.client.player.findMany({
where: {
uid: user.uid
}
});
userInfo.availableProfiles = await Promise.all(players.map(async (player) => {
const uuid: UUID | null = await this.prisma.client.uUID.findFirst({
where: {
pid: player.pid
}
});
return uuid ? { id: uuid.uuid, name: player.name } : null;
})).then(profiles => profiles.filter(profile => profile !== null));
}
return {
accountId: userInfo.sub,
async claims(use: string, scope: string, claims: object, rejected: string[]) {
return userInfo;
}
};
}
/*
oidc-provider
Access Token access_token.issued refresh_token.saved
Access Token refresh_token.saved access_token.issued
upsert Refresh Token Laravel Passport
*/
async saveRefreshTokenToPassport() {
// @ts-ignore
const ctx: oidc.OIDCContext | undefined = oidc.Provider.ctx?.oidc;
if (ctx?.entities.AccessToken && ctx?.entities.RefreshToken) {
const accessToken = ctx.entities.AccessToken;
const refreshToken = ctx.entities.RefreshToken;
await this.prisma.client.passportRefreshToken.upsert({
where: {
id: refreshToken.jti
},
create: {
id: refreshToken.jti,
access_token_id: accessToken.jti,
revoked: false,
expires_at: new Date(getDateWithTimezoneOffset().getTime() + refreshToken.expiration * 1000),
},
update: {}
});
}
}
async getBlessingOption(name: string): Promise<string | null>;
async getBlessingOption(name: string, defaultValue: string): Promise<string>;
async getBlessingOption(name: string, defaultValue?: string): Promise<string | null> {
const option = await this.prisma.client.option.findFirst({
where: {
name: name
},
select: {
value: true
}
});
if (!option) {
if (defaultValue !== undefined) {
return defaultValue;
}
return null;
}
return option.value;
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ESNext",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}