feat(SSO): JWTやSAMLでのSingle Sign-Onの実装 (MisskeyIO#519)
This commit is contained in:
parent
d300a6829f
commit
8c1db331e7
45 changed files with 4094 additions and 1725 deletions
|
@ -60,10 +60,10 @@
|
||||||
"typescript": "5.4.2"
|
"typescript": "5.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "13.6.6",
|
"cypress": "13.7.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"ncp": "2.0.0",
|
"ncp": "2.0.0",
|
||||||
"start-server-and-test": "2.0.3"
|
"start-server-and-test": "2.0.3"
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class IndieAuthClient1707697398681 {
|
export class IndieAuthClient1707697398681 {
|
||||||
name = 'IndieAuthClient1707697398681'
|
name = 'IndieAuthClient1707697398681'
|
||||||
|
|
||||||
|
|
15
packages/backend/migration/1710416761960-single-sign-on.js
Normal file
15
packages/backend/migration/1710416761960-single-sign-on.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
export class SingleSignOn1710416761960 {
|
||||||
|
name = 'SingleSignOn1710416761960'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_type_enum" AS ENUM('saml', 'jwt')`);
|
||||||
|
await queryRunner.query(`CREATE TABLE "sso_service_provider" ("id" character varying(36) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256), "type" "public"."sso_service_provider_type_enum" NOT NULL, "issuer" character varying(512) NOT NULL, "audience" character varying(512) array NOT NULL DEFAULT '{}', "acsUrl" character varying(512) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096), "signatureAlgorithm" character varying(100) NOT NULL, "cipherAlgorithm" character varying(100), "wantAuthnRequestsSigned" boolean NOT NULL DEFAULT false, "wantAssertionsSigned" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0e5fff64534026e48e1c248991a" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_86eee7fa4ae68e4a558dc50961" ON "sso_service_provider" ("createdAt") `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_86eee7fa4ae68e4a558dc50961"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "sso_service_provider"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."sso_service_provider_type_enum"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -65,16 +65,18 @@
|
||||||
"utf-8-validate": "6.0.3"
|
"utf-8-validate": "6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.525.0",
|
"@authenio/samlify-node-xmllint": "2.0.0",
|
||||||
"@aws-sdk/lib-storage": "3.525.1",
|
"@aws-sdk/client-s3": "3.533.0",
|
||||||
"@bull-board/api": "5.14.2",
|
"@aws-sdk/lib-storage": "3.533.0",
|
||||||
"@bull-board/fastify": "5.14.2",
|
"@bull-board/api": "5.15.1",
|
||||||
"@bull-board/ui": "5.14.2",
|
"@bull-board/fastify": "5.15.1",
|
||||||
|
"@bull-board/ui": "5.15.1",
|
||||||
"@discordapp/twemoji": "15.0.2",
|
"@discordapp/twemoji": "15.0.2",
|
||||||
"@fastify/accepts": "4.3.0",
|
"@fastify/accepts": "4.3.0",
|
||||||
"@fastify/cookie": "9.3.1",
|
"@fastify/cookie": "9.3.1",
|
||||||
"@fastify/cors": "9.0.1",
|
"@fastify/cors": "9.0.1",
|
||||||
"@fastify/express": "2.3.0",
|
"@fastify/express": "2.3.0",
|
||||||
|
"@fastify/formbody": "7.4.0",
|
||||||
"@fastify/http-proxy": "9.4.0",
|
"@fastify/http-proxy": "9.4.0",
|
||||||
"@fastify/multipart": "8.1.0",
|
"@fastify/multipart": "8.1.0",
|
||||||
"@fastify/static": "7.0.1",
|
"@fastify/static": "7.0.1",
|
||||||
|
@ -87,7 +89,7 @@
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@simplewebauthn/server": "9.0.3",
|
"@simplewebauthn/server": "9.0.3",
|
||||||
"@sinonjs/fake-timers": "11.2.2",
|
"@sinonjs/fake-timers": "11.2.2",
|
||||||
"@smithy/node-http-handler": "2.4.1",
|
"@smithy/node-http-handler": "2.4.3",
|
||||||
"@swc/cli": "0.1.65",
|
"@swc/cli": "0.1.65",
|
||||||
"@swc/core": "1.3.107",
|
"@swc/core": "1.3.107",
|
||||||
"@twemoji/parser": "15.0.0",
|
"@twemoji/parser": "15.0.0",
|
||||||
|
@ -107,15 +109,16 @@
|
||||||
"cli-highlight": "2.1.11",
|
"cli-highlight": "2.1.11",
|
||||||
"color-convert": "2.0.1",
|
"color-convert": "2.0.1",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "0.5.4",
|
||||||
"date-fns": "3.3.1",
|
"date-fns": "3.4.0",
|
||||||
"deep-email-validator": "0.1.21",
|
"deep-email-validator": "0.1.21",
|
||||||
"fastify": "4.26.2",
|
"fastify": "4.26.2",
|
||||||
|
"fastify-http-errors-enhanced": "5.0.3",
|
||||||
"fastify-raw-body": "4.3.0",
|
"fastify-raw-body": "4.3.0",
|
||||||
"feed": "4.2.2",
|
"feed": "4.2.2",
|
||||||
"file-type": "19.0.0",
|
"file-type": "19.0.0",
|
||||||
"fluent-ffmpeg": "2.1.2",
|
"fluent-ffmpeg": "2.1.2",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"got": "14.2.0",
|
"got": "14.2.1",
|
||||||
"happy-dom": "10.0.3",
|
"happy-dom": "10.0.3",
|
||||||
"hpagent": "1.2.0",
|
"hpagent": "1.2.0",
|
||||||
"htmlescape": "1.1.1",
|
"htmlescape": "1.1.1",
|
||||||
|
@ -124,12 +127,13 @@
|
||||||
"ip-cidr": "3.1.0",
|
"ip-cidr": "3.1.0",
|
||||||
"ipaddr.js": "2.1.0",
|
"ipaddr.js": "2.1.0",
|
||||||
"is-svg": "5.0.0",
|
"is-svg": "5.0.0",
|
||||||
|
"jose": "5.2.3",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"jsdom": "23.2.0",
|
"jsdom": "23.2.0",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"jsonld": "8.3.2",
|
"jsonld": "8.3.2",
|
||||||
"jsrsasign": "11.1.0",
|
"jsrsasign": "11.1.0",
|
||||||
"meilisearch": "0.37.0",
|
"meilisearch": "0.38.0",
|
||||||
"mfm-js": "0.24.0",
|
"mfm-js": "0.24.0",
|
||||||
"microformats-parser": "2.0.2",
|
"microformats-parser": "2.0.2",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
|
@ -139,8 +143,8 @@
|
||||||
"nanoid": "5.0.6",
|
"nanoid": "5.0.6",
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.11",
|
"nodemailer": "6.9.12",
|
||||||
"nsfwjs": "3.0.0",
|
"nsfwjs": "2.4.2",
|
||||||
"oauth": "0.10.0",
|
"oauth": "0.10.0",
|
||||||
"oauth2orize": "1.12.0",
|
"oauth2orize": "1.12.0",
|
||||||
"oauth2orize-pkce": "0.1.2",
|
"oauth2orize-pkce": "0.1.2",
|
||||||
|
@ -165,13 +169,14 @@
|
||||||
"rename": "1.0.4",
|
"rename": "1.0.4",
|
||||||
"rss-parser": "3.13.0",
|
"rss-parser": "3.13.0",
|
||||||
"rxjs": "7.8.1",
|
"rxjs": "7.8.1",
|
||||||
|
"samlify": "2.8.11",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"secure-json-parse": "2.7.0",
|
"secure-json-parse": "2.7.0",
|
||||||
"sharp": "0.33.2",
|
"sharp": "0.33.2",
|
||||||
"slacc": "0.0.10",
|
"slacc": "0.0.10",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"systeminformation": "5.22.0",
|
"systeminformation": "5.22.2",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.3",
|
"tmp": "0.2.3",
|
||||||
"tsc-alias": "1.8.8",
|
"tsc-alias": "1.8.8",
|
||||||
|
@ -182,7 +187,8 @@
|
||||||
"vary": "1.1.2",
|
"vary": "1.1.2",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"xev": "3.0.2"
|
"xev": "3.0.2",
|
||||||
|
"xmlbuilder": "15.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@jest/globals": "29.7.0",
|
"@jest/globals": "29.7.0",
|
||||||
|
@ -203,13 +209,13 @@
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsdom": "21.1.6",
|
"@types/jsdom": "21.1.6",
|
||||||
"@types/jsonld": "1.5.13",
|
"@types/jsonld": "1.5.13",
|
||||||
"@types/jsrsasign": "10.5.12",
|
"@types/jsrsasign": "10.5.13",
|
||||||
"@types/mime-types": "2.1.4",
|
"@types/mime-types": "2.1.4",
|
||||||
"@types/ms": "0.7.34",
|
"@types/ms": "0.7.34",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@types/nodemailer": "6.4.14",
|
"@types/nodemailer": "6.4.14",
|
||||||
"@types/oauth": "0.9.4",
|
"@types/oauth": "0.9.4",
|
||||||
"@types/oauth2orize": "1.11.3",
|
"@types/oauth2orize": "1.11.4",
|
||||||
"@types/oauth2orize-pkce": "0.1.2",
|
"@types/oauth2orize-pkce": "0.1.2",
|
||||||
"@types/pg": "8.11.2",
|
"@types/pg": "8.11.2",
|
||||||
"@types/pug": "2.0.10",
|
"@types/pug": "2.0.10",
|
||||||
|
@ -227,8 +233,8 @@
|
||||||
"@types/vary": "1.1.3",
|
"@types/vary": "1.1.3",
|
||||||
"@types/web-push": "3.6.3",
|
"@types/web-push": "3.6.3",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"aws-sdk-client-mock": "3.0.1",
|
"aws-sdk-client-mock": "3.0.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
|
|
1
packages/backend/src/@types/samlify-xsd-schema-validator.d.ts
vendored
Normal file
1
packages/backend/src/@types/samlify-xsd-schema-validator.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
declare module '@authenio/samlify-xsd-schema-validator';
|
|
@ -55,6 +55,7 @@ export const DI = {
|
||||||
authSessionsRepository: Symbol('authSessionsRepository'),
|
authSessionsRepository: Symbol('authSessionsRepository'),
|
||||||
accessTokensRepository: Symbol('accessTokensRepository'),
|
accessTokensRepository: Symbol('accessTokensRepository'),
|
||||||
signinsRepository: Symbol('signinsRepository'),
|
signinsRepository: Symbol('signinsRepository'),
|
||||||
|
singleSignOnServiceProviderRepository: Symbol('singleSignOnServiceProviderRepository'),
|
||||||
pagesRepository: Symbol('pagesRepository'),
|
pagesRepository: Symbol('pagesRepository'),
|
||||||
pageLikesRepository: Symbol('pageLikesRepository'),
|
pageLikesRepository: Symbol('pageLikesRepository'),
|
||||||
galleryPostsRepository: Symbol('galleryPostsRepository'),
|
galleryPostsRepository: Symbol('galleryPostsRepository'),
|
||||||
|
|
|
@ -36,6 +36,10 @@ export default class Logger {
|
||||||
|
|
||||||
this.logger = pino({
|
this.logger = pino({
|
||||||
name: this.domain,
|
name: this.domain,
|
||||||
|
serializers: {
|
||||||
|
...pino.stdSerializers,
|
||||||
|
err: pino.stdSerializers.errWithCause,
|
||||||
|
},
|
||||||
level: envOption.verbose ? 'debug' : 'info',
|
level: envOption.verbose ? 'debug' : 'info',
|
||||||
depthLimit: 8,
|
depthLimit: 8,
|
||||||
edgeLimit: 128,
|
edgeLimit: 128,
|
||||||
|
@ -63,17 +67,19 @@ export default class Logger {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public error(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
|
public error(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (context === null) context = undefined;
|
if (context === null) context = undefined;
|
||||||
|
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
|
||||||
|
|
||||||
if (x instanceof Error) {
|
if (x instanceof Error) {
|
||||||
context = context ?? {};
|
// eslint-disable-next-line no-param-reassign
|
||||||
context.error = x;
|
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||||
|
|
||||||
if (important) this.logger.fatal({ context, important }, x.toString());
|
if (important) this.logger.fatal({ context, important }, x.toString());
|
||||||
else this.logger.error({ context, important }, x.toString());
|
else this.logger.error({ context, important }, x.toString());
|
||||||
} else if (typeof x === 'object') {
|
} else if (typeof x === 'object') {
|
||||||
context = context ?? {};
|
// eslint-disable-next-line no-param-reassign
|
||||||
context.error = context.error ?? x;
|
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||||
|
|
||||||
if (important) this.logger.fatal({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
if (important) this.logger.fatal({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||||
else this.logger.error({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
else this.logger.error({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||||
|
@ -85,16 +91,18 @@ export default class Logger {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public warn(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
|
public warn(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (context === null) context = undefined;
|
if (context === null) context = undefined;
|
||||||
|
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
|
||||||
|
|
||||||
if (x instanceof Error) {
|
if (x instanceof Error) {
|
||||||
context = context ?? {};
|
// eslint-disable-next-line no-param-reassign
|
||||||
context.error = x;
|
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||||
|
|
||||||
this.logger.warn({ context, important }, x.toString());
|
this.logger.warn({ context, important }, x.toString());
|
||||||
} else if (typeof x === 'object') {
|
} else if (typeof x === 'object') {
|
||||||
context = context ?? {};
|
// eslint-disable-next-line no-param-reassign
|
||||||
context.error = context.error ?? x;
|
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||||
|
|
||||||
this.logger.warn({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
this.logger.warn({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -58,6 +58,7 @@ import {
|
||||||
MiRole,
|
MiRole,
|
||||||
MiRoleAssignment,
|
MiRoleAssignment,
|
||||||
MiSignin,
|
MiSignin,
|
||||||
|
MiSingleSignOnServiceProvider,
|
||||||
MiSwSubscription,
|
MiSwSubscription,
|
||||||
MiUsedUsername,
|
MiUsedUsername,
|
||||||
MiUser,
|
MiUser,
|
||||||
|
@ -325,6 +326,12 @@ const $signinsRepository: Provider = {
|
||||||
inject: [DI.db],
|
inject: [DI.db],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const $singleSignOnServiceProviderRepository: Provider = {
|
||||||
|
provide: DI.singleSignOnServiceProviderRepository,
|
||||||
|
useFactory: (db: DataSource) => db.getRepository(MiSingleSignOnServiceProvider),
|
||||||
|
inject: [DI.db],
|
||||||
|
};
|
||||||
|
|
||||||
const $pagesRepository: Provider = {
|
const $pagesRepository: Provider = {
|
||||||
provide: DI.pagesRepository,
|
provide: DI.pagesRepository,
|
||||||
useFactory: (db: DataSource) => db.getRepository(MiPage),
|
useFactory: (db: DataSource) => db.getRepository(MiPage),
|
||||||
|
@ -538,6 +545,7 @@ const $abuseReportResolversRepository: Provider = {
|
||||||
$authSessionsRepository,
|
$authSessionsRepository,
|
||||||
$accessTokensRepository,
|
$accessTokensRepository,
|
||||||
$signinsRepository,
|
$signinsRepository,
|
||||||
|
$singleSignOnServiceProviderRepository,
|
||||||
$pagesRepository,
|
$pagesRepository,
|
||||||
$pageLikesRepository,
|
$pageLikesRepository,
|
||||||
$galleryPostsRepository,
|
$galleryPostsRepository,
|
||||||
|
@ -609,6 +617,7 @@ const $abuseReportResolversRepository: Provider = {
|
||||||
$authSessionsRepository,
|
$authSessionsRepository,
|
||||||
$accessTokensRepository,
|
$accessTokensRepository,
|
||||||
$signinsRepository,
|
$signinsRepository,
|
||||||
|
$singleSignOnServiceProviderRepository,
|
||||||
$pagesRepository,
|
$pagesRepository,
|
||||||
$pageLikesRepository,
|
$pageLikesRepository,
|
||||||
$galleryPostsRepository,
|
$galleryPostsRepository,
|
||||||
|
|
76
packages/backend/src/models/SingleSignOnServiceProvider.ts
Normal file
76
packages/backend/src/models/SingleSignOnServiceProvider.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrimaryColumn, Entity, Column, Index } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('sso_service_provider')
|
||||||
|
export class MiSingleSignOnServiceProvider {
|
||||||
|
@PrimaryColumn('varchar', {
|
||||||
|
length: 36,
|
||||||
|
})
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
default: () => 'CURRENT_TIMESTAMP',
|
||||||
|
})
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 256, nullable: true,
|
||||||
|
})
|
||||||
|
public name: string | null;
|
||||||
|
|
||||||
|
@Column('enum', {
|
||||||
|
enum: ['saml', 'jwt'],
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
public type: 'saml' | 'jwt';
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 512,
|
||||||
|
})
|
||||||
|
public issuer: string;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
array: true, length: 512, default: '{}',
|
||||||
|
})
|
||||||
|
public audience: string[];
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 512,
|
||||||
|
})
|
||||||
|
public acsUrl: string;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 4096,
|
||||||
|
})
|
||||||
|
public publicKey: string;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 4096, nullable: true,
|
||||||
|
})
|
||||||
|
public privateKey: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 100,
|
||||||
|
})
|
||||||
|
public signatureAlgorithm: string;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 100, nullable: true,
|
||||||
|
})
|
||||||
|
public cipherAlgorithm: string | null;
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
public wantAuthnRequestsSigned: boolean;
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
public wantAssertionsSigned: boolean;
|
||||||
|
}
|
|
@ -49,6 +49,7 @@ import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
||||||
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
||||||
import { MiRelay } from '@/models/Relay.js';
|
import { MiRelay } from '@/models/Relay.js';
|
||||||
import { MiSignin } from '@/models/Signin.js';
|
import { MiSignin } from '@/models/Signin.js';
|
||||||
|
import { MiSingleSignOnServiceProvider } from '@/models/SingleSignOnServiceProvider.js';
|
||||||
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
||||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||||
import { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
|
@ -121,6 +122,7 @@ export {
|
||||||
MiRegistryItem,
|
MiRegistryItem,
|
||||||
MiRelay,
|
MiRelay,
|
||||||
MiSignin,
|
MiSignin,
|
||||||
|
MiSingleSignOnServiceProvider,
|
||||||
MiSwSubscription,
|
MiSwSubscription,
|
||||||
MiUsedUsername,
|
MiUsedUsername,
|
||||||
MiUser,
|
MiUser,
|
||||||
|
@ -192,6 +194,7 @@ export type RegistrationTicketsRepository = Repository<MiRegistrationTicket>;
|
||||||
export type RegistryItemsRepository = Repository<MiRegistryItem>;
|
export type RegistryItemsRepository = Repository<MiRegistryItem>;
|
||||||
export type RelaysRepository = Repository<MiRelay>;
|
export type RelaysRepository = Repository<MiRelay>;
|
||||||
export type SigninsRepository = Repository<MiSignin>;
|
export type SigninsRepository = Repository<MiSignin>;
|
||||||
|
export type SingleSignOnServiceProviderRepository = Repository<MiSingleSignOnServiceProvider>;
|
||||||
export type SwSubscriptionsRepository = Repository<MiSwSubscription>;
|
export type SwSubscriptionsRepository = Repository<MiSwSubscription>;
|
||||||
export type UsedUsernamesRepository = Repository<MiUsedUsername>;
|
export type UsedUsernamesRepository = Repository<MiUsedUsername>;
|
||||||
export type UsersRepository = Repository<MiUser>;
|
export type UsersRepository = Repository<MiUser>;
|
||||||
|
|
|
@ -59,6 +59,7 @@ import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
||||||
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
||||||
import { MiRelay } from '@/models/Relay.js';
|
import { MiRelay } from '@/models/Relay.js';
|
||||||
import { MiSignin } from '@/models/Signin.js';
|
import { MiSignin } from '@/models/Signin.js';
|
||||||
|
import { MiSingleSignOnServiceProvider } from '@/models/SingleSignOnServiceProvider.js';
|
||||||
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
||||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||||
import { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
|
@ -178,6 +179,7 @@ export const entities = [
|
||||||
MiAbuseUserReport,
|
MiAbuseUserReport,
|
||||||
MiRegistrationTicket,
|
MiRegistrationTicket,
|
||||||
MiSignin,
|
MiSignin,
|
||||||
|
MiSingleSignOnServiceProvider,
|
||||||
MiModerationLog,
|
MiModerationLog,
|
||||||
MiClip,
|
MiClip,
|
||||||
MiClipNote,
|
MiClipNote,
|
||||||
|
|
|
@ -28,6 +28,8 @@ import { FeedService } from './web/FeedService.js';
|
||||||
import { UrlPreviewService } from './web/UrlPreviewService.js';
|
import { UrlPreviewService } from './web/UrlPreviewService.js';
|
||||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
import { JWTIdentifyProviderService } from './sso/JWTIdentifyProviderService.js';
|
||||||
|
import { SAMLIdentifyProviderService } from './sso/SAMLIdentifyProviderService.js';
|
||||||
import { MainChannelService } from './api/stream/channels/main.js';
|
import { MainChannelService } from './api/stream/channels/main.js';
|
||||||
import { AdminChannelService } from './api/stream/channels/admin.js';
|
import { AdminChannelService } from './api/stream/channels/admin.js';
|
||||||
import { AntennaChannelService } from './api/stream/channels/antenna.js';
|
import { AntennaChannelService } from './api/stream/channels/antenna.js';
|
||||||
|
@ -89,6 +91,8 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
|
||||||
UserListChannelService,
|
UserListChannelService,
|
||||||
OpenApiServerService,
|
OpenApiServerService,
|
||||||
OAuth2ProviderService,
|
OAuth2ProviderService,
|
||||||
|
JWTIdentifyProviderService,
|
||||||
|
SAMLIdentifyProviderService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
ServerService,
|
ServerService,
|
||||||
|
|
|
@ -33,6 +33,8 @@ import { FileServerService } from './FileServerService.js';
|
||||||
import { ClientServerService } from './web/ClientServerService.js';
|
import { ClientServerService } from './web/ClientServerService.js';
|
||||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
import { JWTIdentifyProviderService } from './sso/JWTIdentifyProviderService.js';
|
||||||
|
import { SAMLIdentifyProviderService } from './sso/SAMLIdentifyProviderService.js';
|
||||||
|
|
||||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
@ -67,6 +69,8 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private oauth2ProviderService: OAuth2ProviderService,
|
private oauth2ProviderService: OAuth2ProviderService,
|
||||||
|
private jwtIdentifyProviderService: JWTIdentifyProviderService,
|
||||||
|
private samlIdentifyProviderService: SAMLIdentifyProviderService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||||
}
|
}
|
||||||
|
@ -117,6 +121,9 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
|
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
|
||||||
fastify.register(this.oauth2ProviderService.createApiServer, { prefix: '/oauth/api' });
|
fastify.register(this.oauth2ProviderService.createApiServer, { prefix: '/oauth/api' });
|
||||||
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
|
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
|
||||||
|
fastify.register(this.samlIdentifyProviderService.createServer, { prefix: '/sso/saml' });
|
||||||
|
fastify.register(this.jwtIdentifyProviderService.createServer, { prefix: '/sso/jwt' });
|
||||||
|
fastify.register(this.jwtIdentifyProviderService.createApiServer, { prefix: '/sso/jwt/api' });
|
||||||
|
|
||||||
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
||||||
const path = request.params.path;
|
const path = request.params.path;
|
||||||
|
|
|
@ -90,6 +90,10 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
|
||||||
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
||||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||||
|
import * as ep___admin_sso_create from './endpoints/admin/sso/create.js';
|
||||||
|
import * as ep___admin_sso_delete from './endpoints/admin/sso/delete.js';
|
||||||
|
import * as ep___admin_sso_list from './endpoints/admin/sso/list.js';
|
||||||
|
import * as ep___admin_sso_update from './endpoints/admin/sso/update.js';
|
||||||
import * as ep___announcements from './endpoints/announcements.js';
|
import * as ep___announcements from './endpoints/announcements.js';
|
||||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||||
|
@ -472,6 +476,10 @@ const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useCla
|
||||||
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
|
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
|
||||||
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
|
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
|
||||||
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
|
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
|
||||||
|
const $admin_sso_create: Provider = { provide: 'ep:admin/sso/create', useClass: ep___admin_sso_create.default };
|
||||||
|
const $admin_sso_delete: Provider = { provide: 'ep:admin/sso/delete', useClass: ep___admin_sso_delete.default };
|
||||||
|
const $admin_sso_list: Provider = { provide: 'ep:admin/sso/list', useClass: ep___admin_sso_list.default };
|
||||||
|
const $admin_sso_update: Provider = { provide: 'ep:admin/sso/update', useClass: ep___admin_sso_update.default };
|
||||||
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
||||||
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
||||||
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
|
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
|
||||||
|
@ -858,6 +866,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$admin_roles_unassign,
|
$admin_roles_unassign,
|
||||||
$admin_roles_updateDefaultPolicies,
|
$admin_roles_updateDefaultPolicies,
|
||||||
$admin_roles_users,
|
$admin_roles_users,
|
||||||
|
$admin_sso_create,
|
||||||
|
$admin_sso_delete,
|
||||||
|
$admin_sso_list,
|
||||||
|
$admin_sso_update,
|
||||||
$announcements,
|
$announcements,
|
||||||
$antennas_create,
|
$antennas_create,
|
||||||
$antennas_delete,
|
$antennas_delete,
|
||||||
|
@ -1238,6 +1250,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$admin_roles_unassign,
|
$admin_roles_unassign,
|
||||||
$admin_roles_updateDefaultPolicies,
|
$admin_roles_updateDefaultPolicies,
|
||||||
$admin_roles_users,
|
$admin_roles_users,
|
||||||
|
$admin_sso_create,
|
||||||
|
$admin_sso_delete,
|
||||||
|
$admin_sso_list,
|
||||||
|
$admin_sso_update,
|
||||||
$announcements,
|
$announcements,
|
||||||
$antennas_create,
|
$antennas_create,
|
||||||
$antennas_delete,
|
$antennas_delete,
|
||||||
|
|
|
@ -90,6 +90,10 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
|
||||||
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
|
||||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||||
|
import * as ep___admin_sso_create from './endpoints/admin/sso/create.js';
|
||||||
|
import * as ep___admin_sso_delete from './endpoints/admin/sso/delete.js';
|
||||||
|
import * as ep___admin_sso_list from './endpoints/admin/sso/list.js';
|
||||||
|
import * as ep___admin_sso_update from './endpoints/admin/sso/update.js';
|
||||||
import * as ep___announcements from './endpoints/announcements.js';
|
import * as ep___announcements from './endpoints/announcements.js';
|
||||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||||
|
@ -470,6 +474,10 @@ const eps = [
|
||||||
['admin/roles/unassign', ep___admin_roles_unassign],
|
['admin/roles/unassign', ep___admin_roles_unassign],
|
||||||
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
||||||
['admin/roles/users', ep___admin_roles_users],
|
['admin/roles/users', ep___admin_roles_users],
|
||||||
|
['admin/sso/create', ep___admin_sso_create],
|
||||||
|
['admin/sso/delete', ep___admin_sso_delete],
|
||||||
|
['admin/sso/list', ep___admin_sso_list],
|
||||||
|
['admin/sso/update', ep___admin_sso_update],
|
||||||
['announcements', ep___announcements],
|
['announcements', ep___announcements],
|
||||||
['antennas/create', ep___antennas_create],
|
['antennas/create', ep___antennas_create],
|
||||||
['antennas/delete', ep___antennas_delete],
|
['antennas/delete', ep___antennas_delete],
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||||
|
@ -70,7 +65,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
const indieAuthClient = await this.indieAuthClientsRepository.insert({
|
const indieAuthClient = await this.indieAuthClientsRepository.insert({
|
||||||
id: ps.id,
|
id: ps.id,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
name: ps.name,
|
name: ps.name ? ps.name : null,
|
||||||
redirectUris: ps.redirectUris,
|
redirectUris: ps.redirectUris,
|
||||||
}).then(r => this.indieAuthClientsRepository.findOneByOrFail({ id: r.identifiers[0].id }));
|
}).then(r => this.indieAuthClientsRepository.findOneByOrFail({ id: r.identifiers[0].id }));
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||||
|
@ -53,7 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
if (client == null) throw new ApiError(meta.errors.noSuchIndieAuthClient);
|
if (client == null) throw new ApiError(meta.errors.noSuchIndieAuthClient);
|
||||||
|
|
||||||
await this.indieAuthClientsRepository.update(client.id, {
|
await this.indieAuthClientsRepository.update(client.id, {
|
||||||
name: ps.name,
|
name: ps.name !== '' ? ps.name : null,
|
||||||
redirectUris: ps.redirectUris,
|
redirectUris: ps.redirectUris,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
159
packages/backend/src/server/api/endpoints/admin/sso/create.ts
Normal file
159
packages/backend/src/server/api/endpoints/admin/sso/create.ts
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['admin'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
requireModerator: true,
|
||||||
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
invalidParamSamlUseCertificate: {
|
||||||
|
message: 'SAML service provider must use certificate.',
|
||||||
|
code: 'INVALID_PARAM',
|
||||||
|
id: 'bb97e559-f23c-4d6a-9e4e-eb5db1f467f9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
res: {
|
||||||
|
type: 'object',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['saml', 'jwt'],
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
audience: {
|
||||||
|
type: 'array',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
items: { type: 'string', nullable: false },
|
||||||
|
},
|
||||||
|
acsUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
publicKey: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
signatureAlgorithm: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
cipherAlgorithm: {
|
||||||
|
type: 'string',
|
||||||
|
optional: true, nullable: true,
|
||||||
|
},
|
||||||
|
wantAuthnRequestsSigned: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
wantAssertionsSigned: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', nullable: true },
|
||||||
|
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
|
||||||
|
issuer: { type: 'string', nullable: false },
|
||||||
|
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
|
||||||
|
acsUrl: { type: 'string', nullable: false },
|
||||||
|
signatureAlgorithm: { type: 'string', nullable: false },
|
||||||
|
cipherAlgorithm: { type: 'string', nullable: true },
|
||||||
|
wantAuthnRequestsSigned: { type: 'boolean', nullable: false, default: false },
|
||||||
|
wantAssertionsSigned: { type: 'boolean', nullable: false, default: true },
|
||||||
|
useCertificate: { type: 'boolean', nullable: false, default: true },
|
||||||
|
secret: { type: 'string', nullable: true },
|
||||||
|
},
|
||||||
|
required: ['type', 'issuer', 'acsUrl', 'signatureAlgorithm', 'useCertificate'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
|
||||||
|
private moderationLogService: ModerationLogService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
if (ps.type === 'saml' && ps.useCertificate === false) {
|
||||||
|
throw new ApiError(meta.errors.invalidParamSamlUseCertificate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { publicKey, privateKey } = ps.useCertificate
|
||||||
|
? await jose.generateKeyPair(ps.signatureAlgorithm).then(async keypair => ({
|
||||||
|
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
|
||||||
|
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
|
||||||
|
}))
|
||||||
|
: { publicKey: ps.secret ?? randomUUID(), privateKey: null };
|
||||||
|
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.insert({
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
name: ps.name ? ps.name : null,
|
||||||
|
type: ps.type,
|
||||||
|
issuer: ps.issuer,
|
||||||
|
audience: ps.audience,
|
||||||
|
acsUrl: ps.acsUrl,
|
||||||
|
publicKey: publicKey,
|
||||||
|
privateKey: privateKey,
|
||||||
|
signatureAlgorithm: ps.signatureAlgorithm,
|
||||||
|
cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null,
|
||||||
|
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
|
||||||
|
wantAssertionsSigned: ps.wantAssertionsSigned,
|
||||||
|
}).then(r => this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: r.identifiers[0].id }));
|
||||||
|
|
||||||
|
this.moderationLogService.log(me, 'createSSOServiceProvider', {
|
||||||
|
serviceId: ssoServiceProvider.id,
|
||||||
|
service: ssoServiceProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ssoServiceProvider.id,
|
||||||
|
createdAt: ssoServiceProvider.createdAt.toISOString(),
|
||||||
|
name: ssoServiceProvider.name,
|
||||||
|
type: ssoServiceProvider.type,
|
||||||
|
issuer: ssoServiceProvider.issuer,
|
||||||
|
audience: ssoServiceProvider.audience,
|
||||||
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
|
publicKey: ssoServiceProvider.publicKey,
|
||||||
|
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
|
||||||
|
cipherAlgorithm: ssoServiceProvider.cipherAlgorithm,
|
||||||
|
wantAuthnRequestsSigned: ssoServiceProvider.wantAuthnRequestsSigned,
|
||||||
|
wantAssertionsSigned: ssoServiceProvider.wantAssertionsSigned,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['admin'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
requireModerator: true,
|
||||||
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchSingleSignOnServiceProvider: {
|
||||||
|
message: 'No such SSO Service Provider',
|
||||||
|
code: 'NO_SUCH_SSO_SP',
|
||||||
|
id: 'ece541d3-6c41-4fc3-a514-fa762b96704a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
|
||||||
|
private moderationLogService: ModerationLogService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
|
||||||
|
|
||||||
|
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
|
||||||
|
|
||||||
|
await this.singleSignOnServiceProviderRepository.delete(service.id);
|
||||||
|
|
||||||
|
this.moderationLogService.log(me, 'deleteSSOServiceProvider', {
|
||||||
|
serviceId: service.id,
|
||||||
|
service: service,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
111
packages/backend/src/server/api/endpoints/admin/sso/list.ts
Normal file
111
packages/backend/src/server/api/endpoints/admin/sso/list.ts
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['admin'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
requireModerator: true,
|
||||||
|
kind: 'read:admin:sso',
|
||||||
|
|
||||||
|
res: {
|
||||||
|
type: 'array',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['saml', 'jwt'],
|
||||||
|
},
|
||||||
|
issuer: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
audience: {
|
||||||
|
type: 'array',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
items: { type: 'string', nullable: false },
|
||||||
|
},
|
||||||
|
acsUrl: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
publicKey: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
signatureAlgorithm: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
cipherAlgorithm: {
|
||||||
|
type: 'string',
|
||||||
|
optional: true, nullable: true,
|
||||||
|
},
|
||||||
|
wantAuthnRequestsSigned: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
wantAssertionsSigned: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||||
|
offset: { type: 'integer', default: 0 },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const query = this.singleSignOnServiceProviderRepository.createQueryBuilder('service');
|
||||||
|
const services = await query.offset(ps.offset).limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
return services.map(service => ({
|
||||||
|
id: service.id,
|
||||||
|
createdAt: service.createdAt.toISOString(),
|
||||||
|
name: service.name,
|
||||||
|
type: service.type,
|
||||||
|
issuer: service.issuer,
|
||||||
|
audience: service.audience,
|
||||||
|
acsUrl: service.acsUrl,
|
||||||
|
publicKey: service.publicKey,
|
||||||
|
signatureAlgorithm: service.signatureAlgorithm,
|
||||||
|
cipherAlgorithm: service.cipherAlgorithm,
|
||||||
|
wantAuthnRequestsSigned: service.wantAuthnRequestsSigned,
|
||||||
|
wantAssertionsSigned: service.wantAssertionsSigned,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['admin'],
|
||||||
|
|
||||||
|
requireCredential: true,
|
||||||
|
requireModerator: true,
|
||||||
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchSingleSignOnServiceProvider: {
|
||||||
|
message: 'No such SSO Service Provider',
|
||||||
|
code: 'NO_SUCH_SSO_SP',
|
||||||
|
id: '2f481db0-23f5-4380-8cb8-704169ffb25b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
issuer: { type: 'string' },
|
||||||
|
audience: { type: 'array', items: { type: 'string', nullable: false } },
|
||||||
|
acsUrl: { type: 'string' },
|
||||||
|
signatureAlgorithm: { type: 'string' },
|
||||||
|
cipherAlgorithm: { type: 'string' },
|
||||||
|
wantAuthnRequestsSigned: { type: 'boolean' },
|
||||||
|
wantAssertionsSigned: { type: 'boolean' },
|
||||||
|
regenerateCertificate: { type: 'boolean' },
|
||||||
|
secret: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
|
||||||
|
private moderationLogService: ModerationLogService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
|
||||||
|
|
||||||
|
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
|
||||||
|
|
||||||
|
const alg = ps.signatureAlgorithm ? ps.signatureAlgorithm : service.signatureAlgorithm;
|
||||||
|
const { publicKey, privateKey } = ps.regenerateCertificate
|
||||||
|
? await jose.generateKeyPair(alg).then(async keypair => ({
|
||||||
|
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
|
||||||
|
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
|
||||||
|
}))
|
||||||
|
: { publicKey: ps.secret ?? undefined, privateKey: undefined };
|
||||||
|
|
||||||
|
await this.singleSignOnServiceProviderRepository.update(service.id, {
|
||||||
|
name: ps.name !== '' ? ps.name : null,
|
||||||
|
issuer: ps.issuer,
|
||||||
|
audience: ps.audience,
|
||||||
|
acsUrl: ps.acsUrl,
|
||||||
|
publicKey: publicKey,
|
||||||
|
privateKey: privateKey,
|
||||||
|
signatureAlgorithm: ps.signatureAlgorithm,
|
||||||
|
cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null,
|
||||||
|
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
|
||||||
|
wantAssertionsSigned: ps.wantAssertionsSigned,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedService = await this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: service.id });
|
||||||
|
|
||||||
|
this.moderationLogService.log(me, 'updateSSOServiceProvider', {
|
||||||
|
serviceId: service.id,
|
||||||
|
before: service,
|
||||||
|
after: updatedService,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,7 +36,7 @@ import type {
|
||||||
AccessTokensRepository,
|
AccessTokensRepository,
|
||||||
IndieAuthClientsRepository,
|
IndieAuthClientsRepository,
|
||||||
UserProfilesRepository,
|
UserProfilesRepository,
|
||||||
UsersRepository
|
UsersRepository,
|
||||||
} from '@/models/_.js';
|
} from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
@ -474,7 +474,7 @@ export class OAuth2ProviderService {
|
||||||
fastify.use('/decision', this.#server.decision((req, done) => {
|
fastify.use('/decision', this.#server.decision((req, done) => {
|
||||||
const { body } = req as OAuth2DecisionRequest;
|
const { body } = req as OAuth2DecisionRequest;
|
||||||
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
|
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
|
||||||
req.user = body.login_token;
|
if (!body.cancel) req.user = body.login_token;
|
||||||
done(null, undefined);
|
done(null, undefined);
|
||||||
}));
|
}));
|
||||||
fastify.use('/decision', this.#server.errorHandler());
|
fastify.use('/decision', this.#server.errorHandler());
|
||||||
|
@ -508,7 +508,7 @@ export class OAuth2ProviderService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.accessTokensRepository.findOneBy({ token });
|
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
reply.code(401);
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
|
@ -525,7 +525,8 @@ export class OAuth2ProviderService {
|
||||||
picture: accessToken.user?.avatarUrl,
|
picture: accessToken.user?.avatarUrl,
|
||||||
email: user?.email,
|
email: user?.email,
|
||||||
email_verified: user?.emailVerified,
|
email_verified: user?.emailVerified,
|
||||||
updated_at: (accessToken.lastUsedAt?.getTime() ?? 0) / 1000,
|
mfa_enabled: user?.twoFactorEnabled,
|
||||||
|
updated_at: (accessToken.user?.updatedAt?.getTime() ?? accessToken.user?.createdAt.getTime() ?? 0) / 1000,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -543,7 +544,7 @@ export class OAuth2ProviderService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.accessTokensRepository.findOneBy({ token });
|
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
|
||||||
reply.code(200);
|
reply.code(200);
|
||||||
|
|
||||||
if (!accessToken) return { active: false };
|
if (!accessToken) return { active: false };
|
||||||
|
|
375
packages/backend/src/server/sso/JWTIdentifyProviderService.ts
Normal file
375
packages/backend/src/server/sso/JWTIdentifyProviderService.ts
Normal file
|
@ -0,0 +1,375 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
|
import pug from 'pug';
|
||||||
|
import fastifyView from '@fastify/view';
|
||||||
|
import fastifyCors from '@fastify/cors';
|
||||||
|
import fastifyFormbody from '@fastify/formbody';
|
||||||
|
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import { JWTPayload } from 'jose';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import type {
|
||||||
|
SingleSignOnServiceProviderRepository,
|
||||||
|
UserProfilesRepository,
|
||||||
|
UsersRepository,
|
||||||
|
} from '@/models/_.js';
|
||||||
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JWTIdentifyProviderService {
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private roleService: RoleService,
|
||||||
|
private cacheService: CacheService,
|
||||||
|
private loggerService: LoggerService,
|
||||||
|
) {
|
||||||
|
this.#logger = this.loggerService.getLogger('sso:jwt');
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||||
|
fastify.register(fastifyFormbody);
|
||||||
|
fastify.register(fastifyCors);
|
||||||
|
fastify.register(fastifyView, {
|
||||||
|
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||||
|
engine: { pug },
|
||||||
|
defaultContext: {
|
||||||
|
version: this.config.version,
|
||||||
|
config: this.config,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.all<{
|
||||||
|
Params: { serviceId: string };
|
||||||
|
Querystring?: { return_to?: string };
|
||||||
|
Body?: { return_to?: string };
|
||||||
|
}>('/:serviceId', async (request, reply) => {
|
||||||
|
const serviceId = request.params.serviceId;
|
||||||
|
const returnTo = request.query?.return_to ?? request.body?.return_to;
|
||||||
|
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: 'c6aafae6-e8b9-420c-a87a-6ac08402165b',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionId = randomUUID();
|
||||||
|
await this.redisClient.set(
|
||||||
|
`sso:jwt:transaction:${transactionId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
serviceId: serviceId,
|
||||||
|
returnTo: returnTo,
|
||||||
|
}),
|
||||||
|
'EX',
|
||||||
|
60 * 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
return await reply.view('sso', {
|
||||||
|
transactionId: transactionId,
|
||||||
|
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
|
||||||
|
kind: 'jwt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: { transaction_id: string; login_token: string; cancel?: string };
|
||||||
|
}>('/authorize', async (request, reply) => {
|
||||||
|
const transactionId = request.body.transaction_id;
|
||||||
|
const token = request.body.login_token;
|
||||||
|
const cancel = !!request.body.cancel;
|
||||||
|
|
||||||
|
if (cancel) {
|
||||||
|
reply.redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await this.redisClient.get(`sso:jwt:transaction:${transactionId}`);
|
||||||
|
if (!transaction) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid transaction id',
|
||||||
|
code: 'INVALID_TRANSACTION_ID',
|
||||||
|
id: '91fa6511-0b33-47d6-bd01-b420d80fcd6a',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serviceId, returnTo } = JSON.parse(transaction);
|
||||||
|
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: 'c038610c-4c11-40ce-9371-131d5720f511',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
reply.status(401).send({
|
||||||
|
error: {
|
||||||
|
message: 'No login token',
|
||||||
|
code: 'NO_LOGIN_TOKEN',
|
||||||
|
id: '399e756c-35cd-459c-a7ba-8cc12eb39eef',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
|
||||||
|
token,
|
||||||
|
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid login token',
|
||||||
|
code: 'INVALID_LOGIN_TOKEN',
|
||||||
|
id: '3b92ee31-9215-447a-805f-df8f15ffb8b2',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
const isAdministrator = await this.roleService.isAdministrator(user);
|
||||||
|
const isModerator = await this.roleService.isModerator(user);
|
||||||
|
const roles = await this.roleService.getUserRoles(user.id);
|
||||||
|
|
||||||
|
const payload: JWTPayload = {
|
||||||
|
name: user.name,
|
||||||
|
preferred_username: user.username,
|
||||||
|
profile: `${this.config.url}/@${user.username}`,
|
||||||
|
picture: user.avatarUrl,
|
||||||
|
email: profile.email,
|
||||||
|
email_verified: profile.emailVerified,
|
||||||
|
mfa_enabled: profile.twoFactorEnabled,
|
||||||
|
updated_at: (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
|
||||||
|
admin: isAdministrator,
|
||||||
|
moderator: isModerator,
|
||||||
|
roles: roles.filter(r => r.isPublic).map(r => r.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ssoServiceProvider.cipherAlgorithm) {
|
||||||
|
const key = ssoServiceProvider.publicKey.startsWith('{')
|
||||||
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
||||||
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
|
const jwt = await new jose.EncryptJWT(payload)
|
||||||
|
.setProtectedHeader({
|
||||||
|
alg: ssoServiceProvider.signatureAlgorithm,
|
||||||
|
enc: ssoServiceProvider.cipherAlgorithm,
|
||||||
|
})
|
||||||
|
.setIssuer(ssoServiceProvider.issuer)
|
||||||
|
.setAudience(ssoServiceProvider.audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('10m')
|
||||||
|
.setJti(randomUUID())
|
||||||
|
.setSubject(user.id)
|
||||||
|
.encrypt(key);
|
||||||
|
|
||||||
|
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
|
||||||
|
userId: user.id,
|
||||||
|
ssoServiceProvider: ssoServiceProvider.id,
|
||||||
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
|
returnTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (returnTo) {
|
||||||
|
reply.redirect(
|
||||||
|
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
reply.redirect(
|
||||||
|
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const key = ssoServiceProvider.privateKey
|
||||||
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
||||||
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
|
const jwt = await new jose.SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
|
||||||
|
.setIssuer(ssoServiceProvider.issuer)
|
||||||
|
.setAudience(ssoServiceProvider.audience)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('10m')
|
||||||
|
.setJti(randomUUID())
|
||||||
|
.setSubject(user.id)
|
||||||
|
.sign(key);
|
||||||
|
|
||||||
|
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
|
||||||
|
userId: user.id,
|
||||||
|
ssoServiceProvider: ssoServiceProvider.id,
|
||||||
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
|
returnTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (returnTo) {
|
||||||
|
reply.redirect(
|
||||||
|
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
reply.redirect(
|
||||||
|
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error('Failed to create JWT', { error: err });
|
||||||
|
const traceableError = err as Error & { code?: string };
|
||||||
|
|
||||||
|
if (traceableError.code) {
|
||||||
|
reply.status(500).send({
|
||||||
|
error: {
|
||||||
|
message: traceableError.message,
|
||||||
|
code: traceableError.code,
|
||||||
|
id: 'a436fa15-20ca-4269-ac4d-ee162fe1f3b0',
|
||||||
|
kind: 'server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(500).send({
|
||||||
|
error: {
|
||||||
|
message: 'Internal server error',
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
id: 'fe1c597c-a515-46a1-860b-bd316b11aff9',
|
||||||
|
kind: 'server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
await this.redisClient.del(`sso:jwt:transaction:${transactionId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createApiServer(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||||
|
fastify.register(fastifyFormbody);
|
||||||
|
fastify.register(fastifyCors);
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Params: { serviceId: string };
|
||||||
|
Body: { jwt: string };
|
||||||
|
}>('/verify/:serviceId', async (request, reply) => {
|
||||||
|
const serviceId = request.params.serviceId;
|
||||||
|
const jwt = request.body.jwt;
|
||||||
|
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: '077e0930-88c1-4f25-bd4e-4da8e34f735b',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ssoServiceProvider.cipherAlgorithm) {
|
||||||
|
const key = ssoServiceProvider.privateKey
|
||||||
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
||||||
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
|
const { payload } = await jose.jwtDecrypt(jwt, key, {
|
||||||
|
issuer: ssoServiceProvider.issuer,
|
||||||
|
audience: ssoServiceProvider.audience,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(200).send({ payload });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
const key = ssoServiceProvider.publicKey.startsWith('{')
|
||||||
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
||||||
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
|
const { payload } = await jose.jwtVerify(jwt, key, {
|
||||||
|
issuer: ssoServiceProvider.issuer,
|
||||||
|
audience: ssoServiceProvider.audience,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(200).send({ payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error('Failed to verify JWT', { error: err });
|
||||||
|
const traceableError = err as Error & { code?: string };
|
||||||
|
|
||||||
|
if (traceableError.code) {
|
||||||
|
reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
message: traceableError.message,
|
||||||
|
code: traceableError.code,
|
||||||
|
id: '843421cf-3ab3-4b1f-ade4-5d5ce1efb6be',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid JWT',
|
||||||
|
code: 'INVALID_JWT',
|
||||||
|
id: '39075dbb-03eb-485f-8ee1-f16b625bcc4d',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
654
packages/backend/src/server/sso/SAMLIdentifyProviderService.ts
Normal file
654
packages/backend/src/server/sso/SAMLIdentifyProviderService.ts
Normal file
|
@ -0,0 +1,654 @@
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as jose from 'jose';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
|
import * as saml from 'samlify';
|
||||||
|
import * as validator from '@authenio/samlify-node-xmllint';
|
||||||
|
import fastifyView from '@fastify/view';
|
||||||
|
import fastifyCors from '@fastify/cors';
|
||||||
|
import fastifyFormbody from '@fastify/formbody';
|
||||||
|
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
|
||||||
|
import pug from 'pug';
|
||||||
|
import xmlbuilder from 'xmlbuilder';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IsNull, Not } from 'typeorm';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
|
import type {
|
||||||
|
MiSingleSignOnServiceProvider,
|
||||||
|
SingleSignOnServiceProviderRepository,
|
||||||
|
UserProfilesRepository,
|
||||||
|
UsersRepository,
|
||||||
|
} from '@/models/_.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SAMLIdentifyProviderService {
|
||||||
|
#logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.config)
|
||||||
|
private config: Config,
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis,
|
||||||
|
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||||
|
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
@Inject(DI.userProfilesRepository)
|
||||||
|
private userProfilesRepository: UserProfilesRepository,
|
||||||
|
|
||||||
|
private roleService: RoleService,
|
||||||
|
private cacheService: CacheService,
|
||||||
|
private loggerService: LoggerService,
|
||||||
|
) {
|
||||||
|
this.#logger = this.loggerService.getLogger('sso:saml');
|
||||||
|
saml.setSchemaValidator(validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createIdPMetadataXml(
|
||||||
|
provider: MiSingleSignOnServiceProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
const today = new Date();
|
||||||
|
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
|
||||||
|
|
||||||
|
const nodes = {
|
||||||
|
'md:EntityDescriptor': {
|
||||||
|
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
||||||
|
'@entityID': provider.issuer,
|
||||||
|
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
|
||||||
|
'md:IDPSSODescriptor': {
|
||||||
|
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
||||||
|
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||||
|
'md:KeyDescriptor': {
|
||||||
|
'@use': 'signing',
|
||||||
|
'ds:KeyInfo': {
|
||||||
|
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||||
|
'ds:X509Data': {
|
||||||
|
'ds:X509Certificate': {
|
||||||
|
'#text': publicKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'md:NameIDFormat': {
|
||||||
|
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||||
|
},
|
||||||
|
'md:SingleSignOnService': [
|
||||||
|
{
|
||||||
|
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||||
|
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||||
|
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return xmlbuilder
|
||||||
|
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||||
|
.end({ pretty: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSPMetadataXml(
|
||||||
|
provider: MiSingleSignOnServiceProvider,
|
||||||
|
): Promise<string> {
|
||||||
|
const today = new Date();
|
||||||
|
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
|
||||||
|
|
||||||
|
const keyDescriptor: unknown[] = [
|
||||||
|
{
|
||||||
|
'@use': 'signing',
|
||||||
|
'ds:KeyInfo': {
|
||||||
|
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||||
|
'ds:X509Data': {
|
||||||
|
'ds:X509Certificate': {
|
||||||
|
'#text': publicKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (provider.cipherAlgorithm) {
|
||||||
|
keyDescriptor.push({
|
||||||
|
'@use': 'encryption',
|
||||||
|
'ds:KeyInfo': {
|
||||||
|
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||||
|
'ds:X509Data': {
|
||||||
|
'ds:X509Certificate': {
|
||||||
|
'#text': publicKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'md:EncryptionMethod': {
|
||||||
|
'@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = {
|
||||||
|
'md:EntityDescriptor': {
|
||||||
|
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
||||||
|
'@entityID': provider.issuer,
|
||||||
|
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
|
||||||
|
'md:SPSSODescriptor': {
|
||||||
|
'@AuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
||||||
|
'@WantAssertionsSigned': provider.wantAssertionsSigned,
|
||||||
|
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||||
|
'md:KeyDescriptor': keyDescriptor,
|
||||||
|
'md:NameIDFormat': {
|
||||||
|
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||||
|
},
|
||||||
|
'md:AssertionConsumerService': {
|
||||||
|
'@index': 1,
|
||||||
|
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||||
|
'@Location': provider.acsUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return xmlbuilder
|
||||||
|
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||||
|
.end({ pretty: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @desc Alternative to lodash.get
|
||||||
|
* @reference https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get
|
||||||
|
* @param obj
|
||||||
|
* @param path
|
||||||
|
* @param defaultValue
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private get(obj: any, path: string, defaultValue: unknown) {
|
||||||
|
return path
|
||||||
|
.split('.')
|
||||||
|
.reduce((a, c) => (a?.[c] ? a[c] : defaultValue || null), obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||||
|
fastify.register(fastifyFormbody);
|
||||||
|
fastify.register(fastifyCors);
|
||||||
|
fastify.register(fastifyView, {
|
||||||
|
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||||
|
engine: { pug },
|
||||||
|
defaultContext: {
|
||||||
|
version: this.config.version,
|
||||||
|
config: this.config,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.all<{
|
||||||
|
Params: { serviceId: string };
|
||||||
|
Querystring?: { SAMLRequest?: string; RelayState?: string };
|
||||||
|
Body?: { SAMLRequest?: string; RelayState?: string };
|
||||||
|
}>('/:serviceId', async (request, reply) => {
|
||||||
|
const serviceId = request.params.serviceId;
|
||||||
|
const binding = request.query?.SAMLRequest ? 'redirect' : 'post';
|
||||||
|
const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest;
|
||||||
|
const relayState = request.query?.RelayState ?? request.body?.RelayState;
|
||||||
|
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml', privateKey: Not(IsNull()) });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: 'e2893d7e-df6f-44cf-8717-42234b8ac0ce',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!samlRequest) {
|
||||||
|
reply.status(400).send({
|
||||||
|
error: {
|
||||||
|
message: 'No SAMLRequest',
|
||||||
|
code: 'NO_SAML_REQUEST',
|
||||||
|
id: 'c58bc7e3-f92e-4879-a6a9-7258a13bc491',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idp = saml.IdentityProvider({
|
||||||
|
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
|
||||||
|
privateKey: await jose
|
||||||
|
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
|
||||||
|
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sp = saml.ServiceProvider({
|
||||||
|
metadata: await this.createSPMetadataXml(ssoServiceProvider),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = await idp.parseLoginRequest(sp, binding, { query: request.query, body: request.body });
|
||||||
|
this.#logger.info('Parsed SAML request', { saml: parsed });
|
||||||
|
|
||||||
|
const transactionId = randomUUID();
|
||||||
|
await this.redisClient.set(
|
||||||
|
`sso:saml:transaction:${transactionId}`,
|
||||||
|
JSON.stringify({
|
||||||
|
serviceId: serviceId,
|
||||||
|
binding: binding,
|
||||||
|
flowResult: parsed,
|
||||||
|
relayState: relayState,
|
||||||
|
}),
|
||||||
|
'EX',
|
||||||
|
60 * 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
return await reply.view('sso', {
|
||||||
|
transactionId: transactionId,
|
||||||
|
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
|
||||||
|
kind: 'saml',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get<{ Params: { serviceId: string } }>(
|
||||||
|
'/:serviceId/metadata',
|
||||||
|
async (request, reply) => {
|
||||||
|
const serviceId = request.params.serviceId;
|
||||||
|
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: '8a6d72e1-3530-4ec0-9d4d-b105fdbb8a2d',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Type', 'application/xml');
|
||||||
|
reply.send(await this.createIdPMetadataXml(ssoServiceProvider));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.post<{
|
||||||
|
Body: { transaction_id: string; login_token: string; cancel?: string };
|
||||||
|
}>('/authorize', async (request, reply) => {
|
||||||
|
const transactionId = request.body.transaction_id;
|
||||||
|
const token = request.body.login_token;
|
||||||
|
const cancel = !!request.body.cancel;
|
||||||
|
|
||||||
|
if (cancel) {
|
||||||
|
reply.redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`);
|
||||||
|
if (!transaction) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid transaction id',
|
||||||
|
code: 'INVALID_TRANSACTION_ID',
|
||||||
|
id: 'cca6ea16-5f04-4d9e-9ef5-8a99bdef3a92',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction);
|
||||||
|
|
||||||
|
const ssoServiceProvider =
|
||||||
|
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
||||||
|
if (!ssoServiceProvider) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid SSO Service Provider id',
|
||||||
|
code: 'INVALID_SSO_SP_ID',
|
||||||
|
id: 'f644adfe-019a-478c-b5a9-897a2556f2b2',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
reply.status(401).send({
|
||||||
|
error: {
|
||||||
|
message: 'No login token',
|
||||||
|
code: 'NO_LOGIN_TOKEN',
|
||||||
|
id: 'cd96295e-0370-433d-a3de-421de4536b7f',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
|
||||||
|
token,
|
||||||
|
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid login token',
|
||||||
|
code: 'INVALID_LOGIN_TOKEN',
|
||||||
|
id: 'a002a4ed-0024-460f-8015-cc5e7c6cd0a7',
|
||||||
|
kind: 'client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
const isAdministrator = await this.roleService.isAdministrator(user);
|
||||||
|
const isModerator = await this.roleService.isModerator(user);
|
||||||
|
const roles = await this.roleService.getUserRoles(user.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idp = saml.IdentityProvider({
|
||||||
|
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
|
||||||
|
privateKey: await jose
|
||||||
|
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
|
||||||
|
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
|
||||||
|
loginResponseTemplate: { context: 'ignored' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sp = saml.ServiceProvider({
|
||||||
|
metadata: await this.createSPMetadataXml(ssoServiceProvider),
|
||||||
|
});
|
||||||
|
|
||||||
|
const samlResponse = await idp.createLoginResponse(
|
||||||
|
sp,
|
||||||
|
flowResult,
|
||||||
|
binding,
|
||||||
|
{},
|
||||||
|
() => {
|
||||||
|
const id = idp.entitySetting.generateID?.() ?? randomUUID();
|
||||||
|
const assertionId = idp.entitySetting.generateID?.() ?? randomUUID();
|
||||||
|
const nowTime = new Date();
|
||||||
|
const fiveMinutesLaterTime = new Date(nowTime.getTime());
|
||||||
|
fiveMinutesLaterTime.setMinutes(fiveMinutesLaterTime.getMinutes() + 5);
|
||||||
|
const now = nowTime.toISOString();
|
||||||
|
const fiveMinutesLater = fiveMinutesLaterTime.toISOString();
|
||||||
|
|
||||||
|
const nodes = {
|
||||||
|
'samlp:Response': {
|
||||||
|
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||||
|
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
|
||||||
|
'@ID': id,
|
||||||
|
'@Version': '2.0',
|
||||||
|
'@IssueInstant': now,
|
||||||
|
'@Destination': ssoServiceProvider.acsUrl,
|
||||||
|
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
|
||||||
|
'saml:Issuer': {
|
||||||
|
'#text': ssoServiceProvider.issuer,
|
||||||
|
},
|
||||||
|
'samlp:Status': {
|
||||||
|
'samlp:StatusCode': {
|
||||||
|
'@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'saml:Assertion': {
|
||||||
|
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||||
|
'@xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
|
||||||
|
'@ID': assertionId,
|
||||||
|
'@Version': '2.0',
|
||||||
|
'@IssueInstant': now,
|
||||||
|
'saml:Issuer': {
|
||||||
|
'#text': ssoServiceProvider.issuer,
|
||||||
|
},
|
||||||
|
'saml:Subject': {
|
||||||
|
'saml:NameID': {
|
||||||
|
'@Format':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||||
|
'#text': user.id,
|
||||||
|
},
|
||||||
|
'saml:SubjectConfirmation': {
|
||||||
|
'@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer',
|
||||||
|
'saml:SubjectConfirmationData': {
|
||||||
|
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
|
||||||
|
'@NotOnOrAfter': fiveMinutesLater,
|
||||||
|
'@Recipient': ssoServiceProvider.acsUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'saml:Conditions': {
|
||||||
|
'@NotBefore': now,
|
||||||
|
'@NotOnOrAfter': fiveMinutesLater,
|
||||||
|
'saml:AudienceRestriction': {
|
||||||
|
'saml:Audience': [
|
||||||
|
{ '#text': ssoServiceProvider.issuer },
|
||||||
|
...ssoServiceProvider.audience.map((audience) => ({
|
||||||
|
'#text': audience,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'saml:AuthnStatement': {
|
||||||
|
'@AuthnInstant': now,
|
||||||
|
'@SessionIndex': assertionId,
|
||||||
|
'@SessionNotOnOrAfter': fiveMinutesLater,
|
||||||
|
'saml:AuthnContext': {
|
||||||
|
'saml:AuthnContextClassRef': {
|
||||||
|
'#text':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'saml:AttributeStatement': {
|
||||||
|
'saml:Attribute': [
|
||||||
|
{
|
||||||
|
'@Name': 'identityprovider',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': this.config.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'uid',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'displayname',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': user.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'name',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': user.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'preferred_username',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': user.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'profile',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': `${this.config.url}/@${user.username}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'picture',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': user.avatarUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'mail',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': profile.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'email',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': profile.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'email_verified',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:boolean',
|
||||||
|
'#text': profile.emailVerified,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'mfa_enabled',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:boolean',
|
||||||
|
'#text': profile.twoFactorEnabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'updated_at',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:integer',
|
||||||
|
'#text': (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'admin',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:boolean',
|
||||||
|
'#text': isAdministrator,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'moderator',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': {
|
||||||
|
'@xsi:type': 'xs:boolean',
|
||||||
|
'#text': isModerator,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@Name': 'roles',
|
||||||
|
'@NameFormat':
|
||||||
|
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||||
|
'saml:AttributeValue': [
|
||||||
|
...roles
|
||||||
|
.filter((r) => r.isPublic)
|
||||||
|
.map((r) => ({
|
||||||
|
'@xsi:type': 'xs:string',
|
||||||
|
'#text': r.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
context: xmlbuilder
|
||||||
|
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||||
|
.end({ pretty: false }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
relayState,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#logger.info(`Rendering SAML response page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`, {
|
||||||
|
userId: user.id,
|
||||||
|
ssoServiceProvider: ssoServiceProvider.id,
|
||||||
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
|
relayState: relayState,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
return await reply.view('sso-saml-post', {
|
||||||
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
|
samlResponse: samlResponse,
|
||||||
|
relyState: relayState ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.#logger.error('Failed to create SAML response', { error: err });
|
||||||
|
const traceableError = err as Error & { code?: string };
|
||||||
|
|
||||||
|
if (traceableError.code) {
|
||||||
|
reply.status(500).send({
|
||||||
|
error: {
|
||||||
|
message: traceableError.message,
|
||||||
|
code: traceableError.code,
|
||||||
|
id: 'a743ff78-8636-4b69-a54f-e3b395564f79',
|
||||||
|
kind: 'server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(500).send({
|
||||||
|
error: {
|
||||||
|
message: 'Internal server error',
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
id: 'b83b7afd-adfc-4baf-8659-34623d639170',
|
||||||
|
kind: 'server',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
await this.redisClient.del(`sso:saml:transaction:${transactionId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
21
packages/backend/src/server/web/views/sso-saml-post.pug
Normal file
21
packages/backend/src/server/web/views/sso-saml-post.pug
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
html
|
||||||
|
body
|
||||||
|
noscript: p
|
||||||
|
| JavaScriptを有効にしてください
|
||||||
|
br
|
||||||
|
| Please turn on your JavaScript
|
||||||
|
|
||||||
|
p
|
||||||
|
| Redirecting...
|
||||||
|
|
||||||
|
form(id='sso', method='post', action=action autocomplete='off')
|
||||||
|
input(type='hidden', name='SAMLResponse', value=samlResponse)
|
||||||
|
|
||||||
|
if relayState !== null
|
||||||
|
input(type='hidden', name='RelayState', value=relayState)
|
||||||
|
|
||||||
|
button(type='submit')
|
||||||
|
| click here if you are not redirected.
|
||||||
|
|
||||||
|
script.
|
||||||
|
document.forms[0].submit();
|
6
packages/backend/src/server/web/views/sso.pug
Normal file
6
packages/backend/src/server/web/views/sso.pug
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
extends ./base
|
||||||
|
|
||||||
|
block meta
|
||||||
|
meta(name='misskey:sso:transaction-id' content=transactionId)
|
||||||
|
meta(name='misskey:sso:service-name' content=serviceName)
|
||||||
|
meta(name='misskey:sso:kind' content=kind)
|
|
@ -88,6 +88,9 @@ export const moderationLogTypes = [
|
||||||
'createIndieAuthClient',
|
'createIndieAuthClient',
|
||||||
'updateIndieAuthClient',
|
'updateIndieAuthClient',
|
||||||
'deleteIndieAuthClient',
|
'deleteIndieAuthClient',
|
||||||
|
'createSSOServiceProvider',
|
||||||
|
'updateSSOServiceProvider',
|
||||||
|
'deleteSSOServiceProvider',
|
||||||
'createAvatarDecoration',
|
'createAvatarDecoration',
|
||||||
'updateAvatarDecoration',
|
'updateAvatarDecoration',
|
||||||
'deleteAvatarDecoration',
|
'deleteAvatarDecoration',
|
||||||
|
@ -273,6 +276,19 @@ export type ModerationLogPayloads = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
client: any;
|
client: any;
|
||||||
};
|
};
|
||||||
|
createSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
service: any;
|
||||||
|
};
|
||||||
|
updateSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
before: any;
|
||||||
|
after: any;
|
||||||
|
};
|
||||||
|
deleteSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
service: any;
|
||||||
|
};
|
||||||
createAvatarDecoration: {
|
createAvatarDecoration: {
|
||||||
avatarDecorationId: string;
|
avatarDecorationId: string;
|
||||||
avatarDecoration: any;
|
avatarDecoration: any;
|
||||||
|
|
|
@ -40,10 +40,10 @@
|
||||||
"chartjs-chart-matrix": "2.0.1",
|
"chartjs-chart-matrix": "2.0.1",
|
||||||
"chartjs-plugin-gradient": "0.6.1",
|
"chartjs-plugin-gradient": "0.6.1",
|
||||||
"chartjs-plugin-zoom": "2.0.1",
|
"chartjs-plugin-zoom": "2.0.1",
|
||||||
"chromatic": "11.0.4",
|
"chromatic": "11.0.8",
|
||||||
"compare-versions": "6.1.0",
|
"compare-versions": "6.1.0",
|
||||||
"cropperjs": "2.0.0-beta.4",
|
"cropperjs": "2.0.0-beta.4",
|
||||||
"date-fns": "3.3.1",
|
"date-fns": "3.4.0",
|
||||||
"escape-regexp": "0.0.1",
|
"escape-regexp": "0.0.1",
|
||||||
"estree-walker": "3.0.3",
|
"estree-walker": "3.0.3",
|
||||||
"eventemitter3": "5.0.1",
|
"eventemitter3": "5.0.1",
|
||||||
|
@ -58,9 +58,9 @@
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
"photoswipe": "5.4.3",
|
"photoswipe": "5.4.3",
|
||||||
"punycode": "2.3.1",
|
"punycode": "2.3.1",
|
||||||
"rollup": "4.12.1",
|
"rollup": "4.13.0",
|
||||||
"sanitize-html": "2.12.1",
|
"sanitize-html": "2.12.1",
|
||||||
"sass": "1.71.1",
|
"sass": "1.72.0",
|
||||||
"shiki": "1.1.7",
|
"shiki": "1.1.7",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"textarea-caret": "3.1.0",
|
"textarea-caret": "3.1.0",
|
||||||
|
@ -71,72 +71,72 @@
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "5.4.2",
|
"typescript": "5.4.2",
|
||||||
"uuid": "9.0.1",
|
"uuid": "9.0.1",
|
||||||
"v-code-diff": "1.9.0",
|
"v-code-diff": "1.10.0",
|
||||||
"vite": "5.1.5",
|
"vite": "5.1.6",
|
||||||
"vue": "3.4.15",
|
"vue": "3.4.15",
|
||||||
"vuedraggable": "next"
|
"vuedraggable": "next"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||||
"@misskey-dev/summaly": "5.0.3",
|
"@misskey-dev/summaly": "5.0.3",
|
||||||
"@storybook/addon-actions": "8.0.0-beta.6",
|
"@storybook/addon-actions": "8.0.0",
|
||||||
"@storybook/addon-essentials": "8.0.0-beta.6",
|
"@storybook/addon-essentials": "8.0.0",
|
||||||
"@storybook/addon-interactions": "8.0.0-beta.6",
|
"@storybook/addon-interactions": "8.0.0",
|
||||||
"@storybook/addon-links": "8.0.0-beta.6",
|
"@storybook/addon-links": "8.0.0",
|
||||||
"@storybook/addon-mdx-gfm": "8.0.0-beta.6",
|
"@storybook/addon-mdx-gfm": "8.0.0",
|
||||||
"@storybook/addon-storysource": "8.0.0-beta.6",
|
"@storybook/addon-storysource": "8.0.0",
|
||||||
"@storybook/blocks": "8.0.0-beta.6",
|
"@storybook/blocks": "8.0.0",
|
||||||
"@storybook/components": "8.0.0-beta.6",
|
"@storybook/components": "8.0.0",
|
||||||
"@storybook/core-events": "8.0.0-beta.6",
|
"@storybook/core-events": "8.0.0",
|
||||||
"@storybook/manager-api": "8.0.0-beta.6",
|
"@storybook/manager-api": "8.0.0",
|
||||||
"@storybook/preview-api": "8.0.0-beta.6",
|
"@storybook/preview-api": "8.0.0",
|
||||||
"@storybook/react": "8.0.0-beta.6",
|
"@storybook/react": "8.0.0",
|
||||||
"@storybook/react-vite": "8.0.0-beta.6",
|
"@storybook/react-vite": "8.0.0",
|
||||||
"@storybook/test": "8.0.0-beta.6",
|
"@storybook/test": "8.0.0",
|
||||||
"@storybook/theming": "8.0.0-beta.6",
|
"@storybook/theming": "8.0.0",
|
||||||
"@storybook/types": "8.0.0-beta.6",
|
"@storybook/types": "8.0.0",
|
||||||
"@storybook/vue3": "8.0.0-beta.6",
|
"@storybook/vue3": "8.0.0",
|
||||||
"@storybook/vue3-vite": "8.0.0-beta.6",
|
"@storybook/vue3-vite": "8.0.0",
|
||||||
"@testing-library/vue": "8.0.2",
|
"@testing-library/vue": "8.0.2",
|
||||||
"@types/escape-regexp": "0.0.3",
|
"@types/escape-regexp": "0.0.3",
|
||||||
"@types/estree": "1.0.5",
|
"@types/estree": "1.0.5",
|
||||||
"@types/matter-js": "0.19.6",
|
"@types/matter-js": "0.19.6",
|
||||||
"@types/micromatch": "4.0.6",
|
"@types/micromatch": "4.0.6",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@types/punycode": "2.1.4",
|
"@types/punycode": "2.1.4",
|
||||||
"@types/sanitize-html": "2.11.0",
|
"@types/sanitize-html": "2.11.0",
|
||||||
"@types/throttle-debounce": "5.0.2",
|
"@types/throttle-debounce": "5.0.2",
|
||||||
"@types/tinycolor2": "1.4.6",
|
"@types/tinycolor2": "1.4.6",
|
||||||
"@types/uuid": "9.0.8",
|
"@types/uuid": "9.0.8",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"@vitest/coverage-v8": "0.34.6",
|
"@vitest/coverage-v8": "0.34.6",
|
||||||
"@vue/runtime-core": "3.4.15",
|
"@vue/runtime-core": "3.4.15",
|
||||||
"acorn": "8.11.3",
|
"acorn": "8.11.3",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "13.6.6",
|
"cypress": "13.7.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-import": "2.29.1",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"eslint-plugin-vue": "9.22.0",
|
"eslint-plugin-vue": "9.23.0",
|
||||||
"fast-glob": "3.3.2",
|
"fast-glob": "3.3.2",
|
||||||
"happy-dom": "13.6.2",
|
"happy-dom": "13.6.2",
|
||||||
"intersection-observer": "0.12.2",
|
"intersection-observer": "0.12.2",
|
||||||
"micromatch": "4.0.5",
|
"micromatch": "4.0.5",
|
||||||
"msw": "2.2.2",
|
"msw": "2.2.3",
|
||||||
"msw-storybook-addon": "2.0.0-beta.1",
|
"msw-storybook-addon": "2.0.0-beta.1",
|
||||||
"nodemon": "3.1.0",
|
"nodemon": "3.1.0",
|
||||||
"prettier": "3.2.5",
|
"prettier": "3.2.5",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"start-server-and-test": "2.0.3",
|
"start-server-and-test": "2.0.3",
|
||||||
"storybook": "8.0.0-beta.6",
|
"storybook": "8.0.0",
|
||||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||||
"vite-plugin-turbosnap": "1.0.3",
|
"vite-plugin-turbosnap": "1.0.3",
|
||||||
"vitest": "0.34.6",
|
"vitest": "0.34.6",
|
||||||
"vitest-fetch-mock": "0.2.2",
|
"vitest-fetch-mock": "0.2.2",
|
||||||
"vue-component-type-helpers": "1.8.27",
|
"vue-component-type-helpers": "2.0.6",
|
||||||
"vue-eslint-parser": "9.4.2",
|
"vue-eslint-parser": "9.4.2",
|
||||||
"vue-tsc": "1.8.27"
|
"vue-tsc": "2.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -137,7 +137,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div class="_gaps">
|
<div class="_gaps">
|
||||||
<MkButton primary full @click="indieAuthAddNew"><i class="ti ti-plus"></i> New</MkButton>
|
<MkButton primary full @click="indieAuthAddNew"><i class="ti ti-plus"></i> New</MkButton>
|
||||||
<MkFolder v-for="(client, index) in indieAuthClients" :key="`${index}-${client.createdAt}`" :defaultOpen="!client.createdAt">
|
<MkFolder v-for="(client, index) in indieAuthClients" :key="`${indieAuthTimestamp}-${index}-${client.createdAt ? client.id : 'new'}`" :defaultOpen="!client.createdAt">
|
||||||
<template #label>{{ client.name || client.id }}</template>
|
<template #label>{{ client.name || client.id }}</template>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<i v-if="client.id" class="ti ti-key"></i>
|
<i v-if="client.id" class="ti ti-key"></i>
|
||||||
|
@ -166,6 +166,72 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder>
|
||||||
|
<template #label>Single Sign-On Service Providers</template>
|
||||||
|
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkButton primary full @click="ssoServiceAddNew"><i class="ti ti-plus"></i> New</MkButton>
|
||||||
|
<MkFolder v-for="(service, index) in ssoServices" :key="`${ssoServiceTimestamp}-${index}-${service.createdAt ? service.id : 'new'}`" :defaultOpen="!service.createdAt">
|
||||||
|
<template #label>{{ service.name || service.id }}</template>
|
||||||
|
<template #icon>
|
||||||
|
<i v-if="service.id" class="ti ti-key"></i>
|
||||||
|
<i v-else class="ti ti-plus"></i>
|
||||||
|
</template>
|
||||||
|
<template v-if="service.name && service.id" #caption>{{ service.id }}</template>
|
||||||
|
|
||||||
|
<div class="_gaps_m">
|
||||||
|
<MkInput v-model="service.id" disabled>
|
||||||
|
<template #label>Service ID</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="service.name">
|
||||||
|
<template #label>Name</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkRadios v-model="service.type">
|
||||||
|
<option value="jwt">JWT</option>
|
||||||
|
<option value="saml">SAML</option>
|
||||||
|
</MkRadios>
|
||||||
|
<MkInput v-model="service.issuer">
|
||||||
|
<template #label>Issuer</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkTextarea v-model="service.audience">
|
||||||
|
<template #label>Audience</template>
|
||||||
|
</MkTextarea>
|
||||||
|
<MkInput v-model="service.acsUrl">
|
||||||
|
<template #label>Assertion Consumer Service URL</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="service.publicKey">
|
||||||
|
<template #label>{{ service['useCertificate'] ? 'Public Key' : 'Secret' }}</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="service.signatureAlgorithm">
|
||||||
|
<template #label>Signature Algorithm</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkInput v-model="service.cipherAlgorithm">
|
||||||
|
<template #label>Cipher Algorithm</template>
|
||||||
|
</MkInput>
|
||||||
|
<MkSwitch v-model="service.wantAuthnRequestsSigned">
|
||||||
|
<template #label>Want Authn Requests Signed</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="service.wantAssertionsSigned">
|
||||||
|
<template #label>Want Assertions Signed</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="service.useCertificate" :disabled="!!service.createdAt">
|
||||||
|
<template #label>Use Certificate</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-if="service.useCertificate" v-model="service.regenerateCertificate">
|
||||||
|
<template #label>Regenerate Certificate</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<div class="buttons _buttons">
|
||||||
|
<MkButton primary @click="ssoServiceSave(service)"><i class="ti ti-device-floppy"></i> Save</MkButton>
|
||||||
|
<MkButton v-if="service.createdAt" warn @click="ssoServiceDelete(service)"><i class="ti ti-trash"></i> Delete</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
<MkButton v-if="ssoServiceHasMore" :class="$style.more" :disabled="!ssoServiceHasMore" primary rounded @click="ssoServiceFetch()">
|
||||||
|
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
|
||||||
|
</MkButton>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
</div>
|
</div>
|
||||||
</FormSuspense>
|
</FormSuspense>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
|
@ -208,8 +274,13 @@ const truemailInstance = ref<string | null>(null);
|
||||||
const truemailAuthKey = ref<string | null>(null);
|
const truemailAuthKey = ref<string | null>(null);
|
||||||
const bannedEmailDomains = ref<string>('');
|
const bannedEmailDomains = ref<string>('');
|
||||||
const indieAuthClients = ref<any[]>([]);
|
const indieAuthClients = ref<any[]>([]);
|
||||||
|
const indieAuthTimestamp = ref(0);
|
||||||
const indieAuthOffset = ref(0);
|
const indieAuthOffset = ref(0);
|
||||||
const indieAuthHasMore = ref(false);
|
const indieAuthHasMore = ref(false);
|
||||||
|
const ssoServices = ref<any[]>([]);
|
||||||
|
const ssoServiceTimestamp = ref(0);
|
||||||
|
const ssoServiceOffset = ref(0);
|
||||||
|
const ssoServiceHasMore = ref(false);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const meta = await misskeyApi('admin/meta');
|
const meta = await misskeyApi('admin/meta');
|
||||||
|
@ -274,12 +345,11 @@ function indieAuthFetch(resetOffset = false) {
|
||||||
offset: indieAuthOffset.value,
|
offset: indieAuthOffset.value,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
}).then(clients => {
|
}).then(clients => {
|
||||||
indieAuthClients.value = indieAuthClients.value.concat(clients.map((client: any) => ({
|
indieAuthClients.value = indieAuthClients.value.concat(clients.map(client => ({
|
||||||
id: client.id,
|
...client,
|
||||||
name: client.name,
|
|
||||||
redirectUris: client.redirectUris.join('\n'),
|
redirectUris: client.redirectUris.join('\n'),
|
||||||
createdAt: client.createdAt,
|
|
||||||
})));
|
})));
|
||||||
|
indieAuthTimestamp.value = Date.now();
|
||||||
indieAuthHasMore.value = clients.length === 10;
|
indieAuthHasMore.value = clients.length === 10;
|
||||||
indieAuthOffset.value += clients.length;
|
indieAuthOffset.value += clients.length;
|
||||||
});
|
});
|
||||||
|
@ -302,7 +372,9 @@ function indieAuthDelete(client) {
|
||||||
}).then(({ canceled }) => {
|
}).then(({ canceled }) => {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
indieAuthClients.value = indieAuthClients.value.filter(x => x !== client);
|
indieAuthClients.value = indieAuthClients.value.filter(x => x !== client);
|
||||||
misskeyApi('admin/indie-auth/delete', client);
|
os.apiWithDialog('admin/indie-auth/delete', client).then(() => {
|
||||||
|
indieAuthFetch(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -314,11 +386,100 @@ async function indieAuthSave(client) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (client.createdAt !== undefined) {
|
if (client.createdAt !== undefined) {
|
||||||
await misskeyApi('admin/indie-auth/update', params);
|
await os.apiWithDialog('admin/indie-auth/update', params).then(() => {
|
||||||
|
indieAuthFetch(true);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await misskeyApi('admin/indie-auth/create', params);
|
await os.apiWithDialog('admin/indie-auth/create', params).then(() => {
|
||||||
|
indieAuthFetch(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssoServiceFetch(resetOffset = false) {
|
||||||
|
if (resetOffset) {
|
||||||
|
ssoServices.value = [];
|
||||||
|
ssoServiceOffset.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
misskeyApi('admin/sso/list', {
|
||||||
|
offsetMode: true,
|
||||||
|
offset: ssoServiceOffset.value,
|
||||||
|
limit: 10,
|
||||||
|
}).then(services => {
|
||||||
|
ssoServices.value = ssoServices.value.concat(services.map(service => ({
|
||||||
|
...service,
|
||||||
|
audience: service.audience.join('\n'),
|
||||||
|
})));
|
||||||
|
ssoServiceTimestamp.value = Date.now();
|
||||||
|
ssoServiceHasMore.value = services.length === 10;
|
||||||
|
ssoServiceOffset.value += services.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ssoServiceFetch(true);
|
||||||
|
|
||||||
|
function ssoServiceAddNew() {
|
||||||
|
ssoServices.value.unshift({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
type: 'jwt',
|
||||||
|
issuer: '',
|
||||||
|
audience: '',
|
||||||
|
acsUrl: '',
|
||||||
|
publicKey: '',
|
||||||
|
signatureAlgorithm: 'HS256',
|
||||||
|
cipherAlgorithm: '',
|
||||||
|
wantAuthnRequestsSigned: false,
|
||||||
|
wantAssertionsSigned: true,
|
||||||
|
useCertificate: false,
|
||||||
|
regenerateCertificate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ssoServiceDelete(service) {
|
||||||
|
os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.tsx.deleteAreYouSure({ x: service.id }),
|
||||||
|
}).then(({ canceled }) => {
|
||||||
|
if (canceled) return;
|
||||||
|
ssoServices.value = ssoServices.value.filter(x => x !== service);
|
||||||
|
os.apiWithDialog('admin/sso/delete', service).then(() => {
|
||||||
|
ssoServiceFetch(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ssoServiceSave(service) {
|
||||||
|
const params = {
|
||||||
|
id: service.id,
|
||||||
|
name: service.name,
|
||||||
|
type: service.type,
|
||||||
|
issuer: service.issuer,
|
||||||
|
audience: service.audience.split('\n'),
|
||||||
|
acsUrl: service.acsUrl,
|
||||||
|
secret: service.publicKey,
|
||||||
|
signatureAlgorithm: service.signatureAlgorithm,
|
||||||
|
cipherAlgorithm: service.cipherAlgorithm,
|
||||||
|
wantAuthnRequestsSigned: service.wantAuthnRequestsSigned,
|
||||||
|
wantAssertionsSigned: service.wantAssertionsSigned,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (service.createdAt !== undefined) {
|
||||||
|
await os.apiWithDialog('admin/sso/update', {
|
||||||
|
...params,
|
||||||
|
regenerateCertificate: service.regenerateCertificate,
|
||||||
|
}).then(() => {
|
||||||
|
ssoServiceFetch(true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await os.apiWithDialog('admin/sso/create', {
|
||||||
|
...params,
|
||||||
|
useCertificate: service.useCertificate,
|
||||||
|
}).then(() => {
|
||||||
|
ssoServiceFetch(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
indieAuthFetch(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = computed(() => []);
|
const headerActions = computed(() => []);
|
||||||
|
|
65
packages/frontend/src/pages/sso.vue
Normal file
65
packages/frontend/src/pages/sso.vue
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header><MkPageHeader/></template>
|
||||||
|
<MkSpacer :contentMax="800">
|
||||||
|
<div v-if="$i">
|
||||||
|
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
|
||||||
|
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
|
||||||
|
<form :class="$style.buttons" :action="`/sso/${kind}/authorize`" accept-charset="utf-8" method="post">
|
||||||
|
<input name="transaction_id" class="mk-input-tr-id-hidden" type="hidden" :value="transactionIdMeta?.content"/>
|
||||||
|
<input name="login_token" class="mk-input-token-hidden" type="hidden" :value="$i.token"/>
|
||||||
|
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||||
|
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
|
||||||
|
<MkSignin @login="onLogin"/>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import MkSignin from '@/components/MkSignin.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { $i, login } from '@/account.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
|
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:transaction-id"]');
|
||||||
|
if (transactionIdMeta) {
|
||||||
|
transactionIdMeta.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:service-name"]')?.content;
|
||||||
|
const kind = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:kind"]')?.content;
|
||||||
|
|
||||||
|
function onLogin(res): void {
|
||||||
|
login(res.i);
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMetadata(() => ({
|
||||||
|
title: 'Single Sign-On',
|
||||||
|
icon: 'ti ti-apps',
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.buttons {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginMessage {
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0 24px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -264,6 +264,9 @@ const routes: RouteDef[] = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/oauth/authorize',
|
path: '/oauth/authorize',
|
||||||
component: page(() => import('@/pages/oauth.vue')),
|
component: page(() => import('@/pages/oauth.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/sso/:kind/:serviceId',
|
||||||
|
component: page(() => import('@/pages/sso.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/tags/:tag',
|
path: '/tags/:tag',
|
||||||
component: page(() => import('@/pages/tag.vue')),
|
component: page(() => import('@/pages/tag.vue')),
|
||||||
|
|
|
@ -26,10 +26,10 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||||
"@types/matter-js": "0.19.6",
|
"@types/matter-js": "0.19.6",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"nodemon": "3.1.0",
|
"nodemon": "3.1.0",
|
||||||
"typescript": "5.4.2"
|
"typescript": "5.4.2"
|
||||||
|
|
|
@ -343,6 +343,24 @@ type AdminShowUsersRequest = operations['admin/show-users']['requestBody']['cont
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type AdminShowUsersResponse = operations['admin/show-users']['responses']['200']['content']['application/json'];
|
type AdminShowUsersResponse = operations['admin/show-users']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoCreateRequest = operations['admin/sso/create']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoCreateResponse = operations['admin/sso/create']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoDeleteRequest = operations['admin/sso/delete']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoListRequest = operations['admin/sso/list']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoListResponse = operations['admin/sso/list']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AdminSsoUpdateRequest = operations['admin/sso/update']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type AdminSuspendUserRequest = operations['admin/suspend-user']['requestBody']['content']['application/json'];
|
type AdminSuspendUserRequest = operations['admin/suspend-user']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
@ -1276,6 +1294,12 @@ declare namespace entities {
|
||||||
AdminRolesUpdateDefaultPoliciesRequest,
|
AdminRolesUpdateDefaultPoliciesRequest,
|
||||||
AdminRolesUsersRequest,
|
AdminRolesUsersRequest,
|
||||||
AdminRolesUsersResponse,
|
AdminRolesUsersResponse,
|
||||||
|
AdminSsoCreateRequest,
|
||||||
|
AdminSsoCreateResponse,
|
||||||
|
AdminSsoDeleteRequest,
|
||||||
|
AdminSsoListRequest,
|
||||||
|
AdminSsoListResponse,
|
||||||
|
AdminSsoUpdateRequest,
|
||||||
AnnouncementsRequest,
|
AnnouncementsRequest,
|
||||||
AnnouncementsResponse,
|
AnnouncementsResponse,
|
||||||
AntennasCreateRequest,
|
AntennasCreateRequest,
|
||||||
|
@ -2457,7 +2481,7 @@ type ModerationLog = {
|
||||||
});
|
});
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner"];
|
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createSSOServiceProvider", "updateSSOServiceProvider", "deleteSSOServiceProvider", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner"];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type MuteCreateRequest = operations['mute/create']['requestBody']['content']['application/json'];
|
type MuteCreateRequest = operations['mute/create']['requestBody']['content']['application/json'];
|
||||||
|
@ -2709,7 +2733,7 @@ type PagesUpdateRequest = operations['pages/update']['requestBody']['content']['
|
||||||
function parse(acct: string): Acct;
|
function parse(acct: string): Acct;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||||
|
|
|
@ -9,12 +9,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "^1.0.0",
|
"@misskey-dev/eslint-plugin": "^1.0.0",
|
||||||
"@readme/openapi-parser": "2.5.0",
|
"@readme/openapi-parser": "2.5.0",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"openapi-types": "12.1.3",
|
"openapi-types": "12.1.3",
|
||||||
"openapi-typescript": "6.7.4",
|
"openapi-typescript": "6.7.5",
|
||||||
"ts-case-convert": "2.0.7",
|
"ts-case-convert": "2.0.7",
|
||||||
"tsx": "4.7.1",
|
"tsx": "4.7.1",
|
||||||
"typescript": "5.4.2"
|
"typescript": "5.4.2"
|
||||||
|
|
|
@ -39,9 +39,9 @@
|
||||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||||
"@swc/jest": "0.2.36",
|
"@swc/jest": "0.2.36",
|
||||||
"@types/jest": "29.5.12",
|
"@types/jest": "29.5.12",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-fetch-mock": "3.0.3",
|
"jest-fetch-mock": "3.0.3",
|
||||||
|
|
|
@ -928,6 +928,50 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
request<E extends 'admin/sso/create', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
request<E extends 'admin/sso/delete', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *read:admin:sso*
|
||||||
|
*/
|
||||||
|
request<E extends 'admin/sso/list', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
request<E extends 'admin/sso/update', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No description provided.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
@ -111,6 +111,12 @@ import type {
|
||||||
AdminRolesUpdateDefaultPoliciesRequest,
|
AdminRolesUpdateDefaultPoliciesRequest,
|
||||||
AdminRolesUsersRequest,
|
AdminRolesUsersRequest,
|
||||||
AdminRolesUsersResponse,
|
AdminRolesUsersResponse,
|
||||||
|
AdminSsoCreateRequest,
|
||||||
|
AdminSsoCreateResponse,
|
||||||
|
AdminSsoDeleteRequest,
|
||||||
|
AdminSsoListRequest,
|
||||||
|
AdminSsoListResponse,
|
||||||
|
AdminSsoUpdateRequest,
|
||||||
AnnouncementsRequest,
|
AnnouncementsRequest,
|
||||||
AnnouncementsResponse,
|
AnnouncementsResponse,
|
||||||
AntennasCreateRequest,
|
AntennasCreateRequest,
|
||||||
|
@ -653,6 +659,10 @@ export type Endpoints = {
|
||||||
'admin/roles/unassign': { req: AdminRolesUnassignRequest; res: EmptyResponse };
|
'admin/roles/unassign': { req: AdminRolesUnassignRequest; res: EmptyResponse };
|
||||||
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
|
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
|
||||||
'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse };
|
'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse };
|
||||||
|
'admin/sso/create': { req: AdminSsoCreateRequest; res: AdminSsoCreateResponse };
|
||||||
|
'admin/sso/delete': { req: AdminSsoDeleteRequest; res: EmptyResponse };
|
||||||
|
'admin/sso/list': { req: AdminSsoListRequest; res: AdminSsoListResponse };
|
||||||
|
'admin/sso/update': { req: AdminSsoUpdateRequest; res: EmptyResponse };
|
||||||
'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse };
|
'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse };
|
||||||
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
|
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
|
||||||
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };
|
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };
|
||||||
|
|
|
@ -113,6 +113,12 @@ export type AdminRolesUnassignRequest = operations['admin/roles/unassign']['requ
|
||||||
export type AdminRolesUpdateDefaultPoliciesRequest = operations['admin/roles/update-default-policies']['requestBody']['content']['application/json'];
|
export type AdminRolesUpdateDefaultPoliciesRequest = operations['admin/roles/update-default-policies']['requestBody']['content']['application/json'];
|
||||||
export type AdminRolesUsersRequest = operations['admin/roles/users']['requestBody']['content']['application/json'];
|
export type AdminRolesUsersRequest = operations['admin/roles/users']['requestBody']['content']['application/json'];
|
||||||
export type AdminRolesUsersResponse = operations['admin/roles/users']['responses']['200']['content']['application/json'];
|
export type AdminRolesUsersResponse = operations['admin/roles/users']['responses']['200']['content']['application/json'];
|
||||||
|
export type AdminSsoCreateRequest = operations['admin/sso/create']['requestBody']['content']['application/json'];
|
||||||
|
export type AdminSsoCreateResponse = operations['admin/sso/create']['responses']['200']['content']['application/json'];
|
||||||
|
export type AdminSsoDeleteRequest = operations['admin/sso/delete']['requestBody']['content']['application/json'];
|
||||||
|
export type AdminSsoListRequest = operations['admin/sso/list']['requestBody']['content']['application/json'];
|
||||||
|
export type AdminSsoListResponse = operations['admin/sso/list']['responses']['200']['content']['application/json'];
|
||||||
|
export type AdminSsoUpdateRequest = operations['admin/sso/update']['requestBody']['content']['application/json'];
|
||||||
export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json'];
|
export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json'];
|
||||||
export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
||||||
export type AntennasCreateRequest = operations['antennas/create']['requestBody']['content']['application/json'];
|
export type AntennasCreateRequest = operations['antennas/create']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -769,6 +769,42 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['admin/roles/users'];
|
post: operations['admin/roles/users'];
|
||||||
};
|
};
|
||||||
|
'/admin/sso/create': {
|
||||||
|
/**
|
||||||
|
* admin/sso/create
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
post: operations['admin/sso/create'];
|
||||||
|
};
|
||||||
|
'/admin/sso/delete': {
|
||||||
|
/**
|
||||||
|
* admin/sso/delete
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
post: operations['admin/sso/delete'];
|
||||||
|
};
|
||||||
|
'/admin/sso/list': {
|
||||||
|
/**
|
||||||
|
* admin/sso/list
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *read:admin:sso*
|
||||||
|
*/
|
||||||
|
post: operations['admin/sso/list'];
|
||||||
|
};
|
||||||
|
'/admin/sso/update': {
|
||||||
|
/**
|
||||||
|
* admin/sso/update
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
post: operations['admin/sso/update'];
|
||||||
|
};
|
||||||
'/announcements': {
|
'/announcements': {
|
||||||
/**
|
/**
|
||||||
* announcements
|
* announcements
|
||||||
|
@ -10287,6 +10323,272 @@ export type operations = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* admin/sso/create
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
'admin/sso/create': {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
name?: string | null;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'saml' | 'jwt';
|
||||||
|
issuer: string;
|
||||||
|
/** @default [] */
|
||||||
|
audience?: string[];
|
||||||
|
acsUrl: string;
|
||||||
|
signatureAlgorithm: string;
|
||||||
|
cipherAlgorithm?: string | null;
|
||||||
|
/** @default false */
|
||||||
|
wantAuthnRequestsSigned?: boolean;
|
||||||
|
/** @default true */
|
||||||
|
wantAssertionsSigned?: boolean;
|
||||||
|
/** @default true */
|
||||||
|
useCertificate: boolean;
|
||||||
|
secret?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (with results) */
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
id: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
name: string | null;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'saml' | 'jwt';
|
||||||
|
issuer: string;
|
||||||
|
audience: string[];
|
||||||
|
acsUrl: string;
|
||||||
|
publicKey: string;
|
||||||
|
signatureAlgorithm: string;
|
||||||
|
cipherAlgorithm?: string | null;
|
||||||
|
wantAuthnRequestsSigned: boolean;
|
||||||
|
wantAssertionsSigned: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* admin/sso/delete
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
'admin/sso/delete': {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (without any results) */
|
||||||
|
204: {
|
||||||
|
content: never;
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* admin/sso/list
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *read:admin:sso*
|
||||||
|
*/
|
||||||
|
'admin/sso/list': {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
/** @default 10 */
|
||||||
|
limit?: number;
|
||||||
|
/** @default 0 */
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (with results) */
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': ({
|
||||||
|
id: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
name: string | null;
|
||||||
|
/** @enum {string} */
|
||||||
|
type: 'saml' | 'jwt';
|
||||||
|
issuer: string;
|
||||||
|
audience: string[];
|
||||||
|
acsUrl: string;
|
||||||
|
publicKey: string;
|
||||||
|
signatureAlgorithm: string;
|
||||||
|
cipherAlgorithm?: string | null;
|
||||||
|
wantAuthnRequestsSigned: boolean;
|
||||||
|
wantAssertionsSigned: boolean;
|
||||||
|
})[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* admin/sso/update
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *Yes* / **Permission**: *write:admin:sso*
|
||||||
|
*/
|
||||||
|
'admin/sso/update': {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
issuer?: string;
|
||||||
|
audience?: string[];
|
||||||
|
acsUrl?: string;
|
||||||
|
signatureAlgorithm?: string;
|
||||||
|
cipherAlgorithm?: string;
|
||||||
|
wantAuthnRequestsSigned?: boolean;
|
||||||
|
wantAssertionsSigned?: boolean;
|
||||||
|
regenerateCertificate?: boolean;
|
||||||
|
secret?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (without any results) */
|
||||||
|
204: {
|
||||||
|
content: never;
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* announcements
|
* announcements
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
|
@ -89,6 +89,8 @@ export const permissions = [
|
||||||
'write:admin:promo',
|
'write:admin:promo',
|
||||||
'write:admin:drive',
|
'write:admin:drive',
|
||||||
'read:admin:drive',
|
'read:admin:drive',
|
||||||
|
'write:admin:sso',
|
||||||
|
'read:admin:sso',
|
||||||
'write:admin:ad',
|
'write:admin:ad',
|
||||||
'read:admin:ad',
|
'read:admin:ad',
|
||||||
'write:invite-codes',
|
'write:invite-codes',
|
||||||
|
@ -136,6 +138,9 @@ export const moderationLogTypes = [
|
||||||
'createIndieAuthClient',
|
'createIndieAuthClient',
|
||||||
'updateIndieAuthClient',
|
'updateIndieAuthClient',
|
||||||
'deleteIndieAuthClient',
|
'deleteIndieAuthClient',
|
||||||
|
'createSSOServiceProvider',
|
||||||
|
'updateSSOServiceProvider',
|
||||||
|
'deleteSSOServiceProvider',
|
||||||
'createAvatarDecoration',
|
'createAvatarDecoration',
|
||||||
'updateAvatarDecoration',
|
'updateAvatarDecoration',
|
||||||
'deleteAvatarDecoration',
|
'deleteAvatarDecoration',
|
||||||
|
@ -321,6 +326,19 @@ export type ModerationLogPayloads = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
client: any;
|
client: any;
|
||||||
};
|
};
|
||||||
|
createSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
service: any;
|
||||||
|
};
|
||||||
|
updateSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
before: any;
|
||||||
|
after: any;
|
||||||
|
};
|
||||||
|
deleteSSOServiceProvider: {
|
||||||
|
serviceId: string;
|
||||||
|
service: any;
|
||||||
|
};
|
||||||
createAvatarDecoration: {
|
createAvatarDecoration: {
|
||||||
avatarDecorationId: string;
|
avatarDecorationId: string;
|
||||||
avatarDecoration: any;
|
avatarDecoration: any;
|
||||||
|
|
|
@ -25,9 +25,9 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||||
"@types/node": "20.11.25",
|
"@types/node": "20.11.27",
|
||||||
"@typescript-eslint/eslint-plugin": "7.1.1",
|
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"nodemon": "3.1.0",
|
"nodemon": "3.1.0",
|
||||||
"typescript": "5.4.2"
|
"typescript": "5.4.2"
|
||||||
|
|
|
@ -15,8 +15,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||||
"@typescript-eslint/parser": "7.1.1",
|
"@types/serviceworker": "0.0.84",
|
||||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
"@typescript-eslint/parser": "7.2.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-import": "2.29.1",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"nodemon": "3.1.0",
|
"nodemon": "3.1.0",
|
||||||
|
|
3333
pnpm-lock.yaml
3333
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue