1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-27 14:28:49 +09:00

feat(SSO): JWTやSAMLでのSingle Sign-Onの実装 (MisskeyIO#519)

This commit is contained in:
まっちゃとーにゅ 2024-03-15 01:30:56 +09:00 committed by GitHub
parent d300a6829f
commit 8c1db331e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 4094 additions and 1725 deletions

View File

@ -60,10 +60,10 @@
"typescript": "5.4.2"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"cross-env": "7.0.3",
"cypress": "13.6.6",
"cypress": "13.7.0",
"eslint": "8.57.0",
"ncp": "2.0.0",
"start-server-and-test": "2.0.3"

View File

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class IndieAuthClient1707697398681 {
name = 'IndieAuthClient1707697398681'

View 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"`);
}
}

View File

@ -65,16 +65,18 @@
"utf-8-validate": "6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "3.525.0",
"@aws-sdk/lib-storage": "3.525.1",
"@bull-board/api": "5.14.2",
"@bull-board/fastify": "5.14.2",
"@bull-board/ui": "5.14.2",
"@authenio/samlify-node-xmllint": "2.0.0",
"@aws-sdk/client-s3": "3.533.0",
"@aws-sdk/lib-storage": "3.533.0",
"@bull-board/api": "5.15.1",
"@bull-board/fastify": "5.15.1",
"@bull-board/ui": "5.15.1",
"@discordapp/twemoji": "15.0.2",
"@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.3.1",
"@fastify/cors": "9.0.1",
"@fastify/express": "2.3.0",
"@fastify/formbody": "7.4.0",
"@fastify/http-proxy": "9.4.0",
"@fastify/multipart": "8.1.0",
"@fastify/static": "7.0.1",
@ -87,7 +89,7 @@
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "9.0.3",
"@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/core": "1.3.107",
"@twemoji/parser": "15.0.0",
@ -107,15 +109,16 @@
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "3.3.1",
"date-fns": "3.4.0",
"deep-email-validator": "0.1.21",
"fastify": "4.26.2",
"fastify-http-errors-enhanced": "5.0.3",
"fastify-raw-body": "4.3.0",
"feed": "4.2.2",
"file-type": "19.0.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "14.2.0",
"got": "14.2.1",
"happy-dom": "10.0.3",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
@ -124,12 +127,13 @@
"ip-cidr": "3.1.0",
"ipaddr.js": "2.1.0",
"is-svg": "5.0.0",
"jose": "5.2.3",
"js-yaml": "4.1.0",
"jsdom": "23.2.0",
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.1.0",
"meilisearch": "0.37.0",
"meilisearch": "0.38.0",
"mfm-js": "0.24.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
@ -139,8 +143,8 @@
"nanoid": "5.0.6",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.9.11",
"nsfwjs": "3.0.0",
"nodemailer": "6.9.12",
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
@ -165,13 +169,14 @@
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"samlify": "2.8.11",
"sanitize-html": "2.12.1",
"secure-json-parse": "2.7.0",
"sharp": "0.33.2",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.22.0",
"systeminformation": "5.22.2",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
"tsc-alias": "1.8.8",
@ -182,7 +187,8 @@
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.16.0",
"xev": "3.0.2"
"xev": "3.0.2",
"xmlbuilder": "15.1.1"
},
"devDependencies": {
"@jest/globals": "29.7.0",
@ -203,13 +209,13 @@
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12",
"@types/jsrsasign": "10.5.13",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "20.11.25",
"@types/node": "20.11.27",
"@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3",
"@types/oauth2orize": "1.11.4",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.2",
"@types/pug": "2.0.10",
@ -227,8 +233,8 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.3",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"aws-sdk-client-mock": "3.0.1",
"cross-env": "7.0.3",
"eslint": "8.57.0",

View File

@ -0,0 +1 @@
declare module '@authenio/samlify-xsd-schema-validator';

View File

@ -55,6 +55,7 @@ export const DI = {
authSessionsRepository: Symbol('authSessionsRepository'),
accessTokensRepository: Symbol('accessTokensRepository'),
signinsRepository: Symbol('signinsRepository'),
singleSignOnServiceProviderRepository: Symbol('singleSignOnServiceProviderRepository'),
pagesRepository: Symbol('pagesRepository'),
pageLikesRepository: Symbol('pageLikesRepository'),
galleryPostsRepository: Symbol('galleryPostsRepository'),

View File

@ -36,6 +36,10 @@ export default class Logger {
this.logger = pino({
name: this.domain,
serializers: {
...pino.stdSerializers,
err: pino.stdSerializers.errWithCause,
},
level: envOption.verbose ? 'debug' : 'info',
depthLimit: 8,
edgeLimit: 128,
@ -63,17 +67,19 @@ export default class Logger {
@bindThis
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?.error) context.error = pino.stdSerializers.errWithCause(context.error);
if (x instanceof Error) {
context = context ?? {};
context.error = x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
if (important) this.logger.fatal({ context, important }, x.toString());
else this.logger.error({ context, important }, x.toString());
} else if (typeof x === 'object') {
context = context ?? {};
context.error = context.error ?? x;
// eslint-disable-next-line no-param-reassign
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}`);
else this.logger.error({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
@ -85,16 +91,18 @@ export default class Logger {
@bindThis
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?.error) context.error = pino.stdSerializers.errWithCause(context.error);
if (x instanceof Error) {
context = context ?? {};
context.error = x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
this.logger.warn({ context, important }, x.toString());
} else if (typeof x === 'object') {
context = context ?? {};
context.error = context.error ?? x;
// eslint-disable-next-line no-param-reassign
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}`);
} else {

View File

@ -58,6 +58,7 @@ import {
MiRole,
MiRoleAssignment,
MiSignin,
MiSingleSignOnServiceProvider,
MiSwSubscription,
MiUsedUsername,
MiUser,
@ -325,6 +326,12 @@ const $signinsRepository: Provider = {
inject: [DI.db],
};
const $singleSignOnServiceProviderRepository: Provider = {
provide: DI.singleSignOnServiceProviderRepository,
useFactory: (db: DataSource) => db.getRepository(MiSingleSignOnServiceProvider),
inject: [DI.db],
};
const $pagesRepository: Provider = {
provide: DI.pagesRepository,
useFactory: (db: DataSource) => db.getRepository(MiPage),
@ -538,6 +545,7 @@ const $abuseReportResolversRepository: Provider = {
$authSessionsRepository,
$accessTokensRepository,
$signinsRepository,
$singleSignOnServiceProviderRepository,
$pagesRepository,
$pageLikesRepository,
$galleryPostsRepository,
@ -609,6 +617,7 @@ const $abuseReportResolversRepository: Provider = {
$authSessionsRepository,
$accessTokensRepository,
$signinsRepository,
$singleSignOnServiceProviderRepository,
$pagesRepository,
$pageLikesRepository,
$galleryPostsRepository,

View 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;
}

View File

@ -49,6 +49,7 @@ import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
import { MiRegistryItem } from '@/models/RegistryItem.js';
import { MiRelay } from '@/models/Relay.js';
import { MiSignin } from '@/models/Signin.js';
import { MiSingleSignOnServiceProvider } from '@/models/SingleSignOnServiceProvider.js';
import { MiSwSubscription } from '@/models/SwSubscription.js';
import { MiUsedUsername } from '@/models/UsedUsername.js';
import { MiUser } from '@/models/User.js';
@ -121,6 +122,7 @@ export {
MiRegistryItem,
MiRelay,
MiSignin,
MiSingleSignOnServiceProvider,
MiSwSubscription,
MiUsedUsername,
MiUser,
@ -192,6 +194,7 @@ export type RegistrationTicketsRepository = Repository<MiRegistrationTicket>;
export type RegistryItemsRepository = Repository<MiRegistryItem>;
export type RelaysRepository = Repository<MiRelay>;
export type SigninsRepository = Repository<MiSignin>;
export type SingleSignOnServiceProviderRepository = Repository<MiSingleSignOnServiceProvider>;
export type SwSubscriptionsRepository = Repository<MiSwSubscription>;
export type UsedUsernamesRepository = Repository<MiUsedUsername>;
export type UsersRepository = Repository<MiUser>;

View File

@ -59,6 +59,7 @@ import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
import { MiRegistryItem } from '@/models/RegistryItem.js';
import { MiRelay } from '@/models/Relay.js';
import { MiSignin } from '@/models/Signin.js';
import { MiSingleSignOnServiceProvider } from '@/models/SingleSignOnServiceProvider.js';
import { MiSwSubscription } from '@/models/SwSubscription.js';
import { MiUsedUsername } from '@/models/UsedUsername.js';
import { MiUser } from '@/models/User.js';
@ -178,6 +179,7 @@ export const entities = [
MiAbuseUserReport,
MiRegistrationTicket,
MiSignin,
MiSingleSignOnServiceProvider,
MiModerationLog,
MiClip,
MiClipNote,

View File

@ -28,6 +28,8 @@ import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.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 { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js';
@ -89,6 +91,8 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
UserListChannelService,
OpenApiServerService,
OAuth2ProviderService,
JWTIdentifyProviderService,
SAMLIdentifyProviderService,
],
exports: [
ServerService,

View File

@ -33,6 +33,8 @@ import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.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));
@ -67,6 +69,8 @@ export class ServerService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private loggerService: LoggerService,
private oauth2ProviderService: OAuth2ProviderService,
private jwtIdentifyProviderService: JWTIdentifyProviderService,
private samlIdentifyProviderService: SAMLIdentifyProviderService,
) {
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.createApiServer, { prefix: '/oauth/api' });
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) => {
const path = request.params.path;

View File

@ -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_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_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___antennas_create from './endpoints/antennas/create.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_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_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 $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 };
@ -858,6 +866,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$admin_sso_create,
$admin_sso_delete,
$admin_sso_list,
$admin_sso_update,
$announcements,
$antennas_create,
$antennas_delete,
@ -1238,6 +1250,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$admin_sso_create,
$admin_sso_delete,
$admin_sso_list,
$admin_sso_update,
$announcements,
$antennas_create,
$antennas_delete,

View File

@ -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_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_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___antennas_create from './endpoints/antennas/create.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/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['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],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],

View File

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.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({
id: ps.id,
createdAt: new Date(),
name: ps.name,
name: ps.name ? ps.name : null,
redirectUris: ps.redirectUris,
}).then(r => this.indieAuthClientsRepository.findOneByOrFail({ id: r.identifiers[0].id }));

View File

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { IndieAuthClientsRepository } from '@/models/_.js';

View File

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { IndieAuthClientsRepository } from '@/models/_.js';

View File

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.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);
await this.indieAuthClientsRepository.update(client.id, {
name: ps.name,
name: ps.name !== '' ? ps.name : null,
redirectUris: ps.redirectUris,
});

View 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,
};
});
}
}

View File

@ -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,
});
});
}
}

View 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,
}));
});
}
}

View File

@ -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,
});
});
}
}

View File

@ -36,7 +36,7 @@ import type {
AccessTokensRepository,
IndieAuthClientsRepository,
UserProfilesRepository,
UsersRepository
UsersRepository,
} from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
@ -474,7 +474,7 @@ export class OAuth2ProviderService {
fastify.use('/decision', this.#server.decision((req, done) => {
const { body } = req as OAuth2DecisionRequest;
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);
}));
fastify.use('/decision', this.#server.errorHandler());
@ -508,7 +508,7 @@ export class OAuth2ProviderService {
return;
}
const accessToken = await this.accessTokensRepository.findOneBy({ token });
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
if (!accessToken) {
reply.code(401);
return;
@ -525,7 +525,8 @@ export class OAuth2ProviderService {
picture: accessToken.user?.avatarUrl,
email: user?.email,
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;
}
const accessToken = await this.accessTokensRepository.findOneBy({ token });
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
reply.code(200);
if (!accessToken) return { active: false };

View 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;
}
});
}
}

View 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}`);
}
});
}
}

View 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();

View 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)

View File

@ -88,6 +88,9 @@ export const moderationLogTypes = [
'createIndieAuthClient',
'updateIndieAuthClient',
'deleteIndieAuthClient',
'createSSOServiceProvider',
'updateSSOServiceProvider',
'deleteSSOServiceProvider',
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
@ -273,6 +276,19 @@ export type ModerationLogPayloads = {
clientId: string;
client: any;
};
createSSOServiceProvider: {
serviceId: string;
service: any;
};
updateSSOServiceProvider: {
serviceId: string;
before: any;
after: any;
};
deleteSSOServiceProvider: {
serviceId: string;
service: any;
};
createAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;

View File

@ -40,10 +40,10 @@
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "11.0.4",
"chromatic": "11.0.8",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4",
"date-fns": "3.3.1",
"date-fns": "3.4.0",
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
@ -58,9 +58,9 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.3",
"punycode": "2.3.1",
"rollup": "4.12.1",
"rollup": "4.13.0",
"sanitize-html": "2.12.1",
"sass": "1.71.1",
"sass": "1.72.0",
"shiki": "1.1.7",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
@ -71,72 +71,72 @@
"tsconfig-paths": "4.2.0",
"typescript": "5.4.2",
"uuid": "9.0.1",
"v-code-diff": "1.9.0",
"vite": "5.1.5",
"v-code-diff": "1.10.0",
"vite": "5.1.6",
"vue": "3.4.15",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@misskey-dev/summaly": "5.0.3",
"@storybook/addon-actions": "8.0.0-beta.6",
"@storybook/addon-essentials": "8.0.0-beta.6",
"@storybook/addon-interactions": "8.0.0-beta.6",
"@storybook/addon-links": "8.0.0-beta.6",
"@storybook/addon-mdx-gfm": "8.0.0-beta.6",
"@storybook/addon-storysource": "8.0.0-beta.6",
"@storybook/blocks": "8.0.0-beta.6",
"@storybook/components": "8.0.0-beta.6",
"@storybook/core-events": "8.0.0-beta.6",
"@storybook/manager-api": "8.0.0-beta.6",
"@storybook/preview-api": "8.0.0-beta.6",
"@storybook/react": "8.0.0-beta.6",
"@storybook/react-vite": "8.0.0-beta.6",
"@storybook/test": "8.0.0-beta.6",
"@storybook/theming": "8.0.0-beta.6",
"@storybook/types": "8.0.0-beta.6",
"@storybook/vue3": "8.0.0-beta.6",
"@storybook/vue3-vite": "8.0.0-beta.6",
"@storybook/addon-actions": "8.0.0",
"@storybook/addon-essentials": "8.0.0",
"@storybook/addon-interactions": "8.0.0",
"@storybook/addon-links": "8.0.0",
"@storybook/addon-mdx-gfm": "8.0.0",
"@storybook/addon-storysource": "8.0.0",
"@storybook/blocks": "8.0.0",
"@storybook/components": "8.0.0",
"@storybook/core-events": "8.0.0",
"@storybook/manager-api": "8.0.0",
"@storybook/preview-api": "8.0.0",
"@storybook/react": "8.0.0",
"@storybook/react-vite": "8.0.0",
"@storybook/test": "8.0.0",
"@storybook/theming": "8.0.0",
"@storybook/types": "8.0.0",
"@storybook/vue3": "8.0.0",
"@storybook/vue3-vite": "8.0.0",
"@testing-library/vue": "8.0.2",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.6",
"@types/micromatch": "4.0.6",
"@types/node": "20.11.25",
"@types/node": "20.11.27",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.8",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.4.15",
"acorn": "8.11.3",
"cross-env": "7.0.3",
"cypress": "13.6.6",
"cypress": "13.7.0",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.22.0",
"eslint-plugin-vue": "9.23.0",
"fast-glob": "3.3.2",
"happy-dom": "13.6.2",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "2.2.2",
"msw": "2.2.3",
"msw-storybook-addon": "2.0.0-beta.1",
"nodemon": "3.1.0",
"prettier": "3.2.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"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",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "0.34.6",
"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-tsc": "1.8.27"
"vue-tsc": "2.0.6"
}
}

View File

@ -137,7 +137,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<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 #icon>
<i v-if="client.id" class="ti ti-key"></i>
@ -166,6 +166,72 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
</div>
</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>
</FormSuspense>
</MkSpacer>
@ -208,8 +274,13 @@ const truemailInstance = ref<string | null>(null);
const truemailAuthKey = ref<string | null>(null);
const bannedEmailDomains = ref<string>('');
const indieAuthClients = ref<any[]>([]);
const indieAuthTimestamp = ref(0);
const indieAuthOffset = ref(0);
const indieAuthHasMore = ref(false);
const ssoServices = ref<any[]>([]);
const ssoServiceTimestamp = ref(0);
const ssoServiceOffset = ref(0);
const ssoServiceHasMore = ref(false);
async function init() {
const meta = await misskeyApi('admin/meta');
@ -274,12 +345,11 @@ function indieAuthFetch(resetOffset = false) {
offset: indieAuthOffset.value,
limit: 10,
}).then(clients => {
indieAuthClients.value = indieAuthClients.value.concat(clients.map((client: any) => ({
id: client.id,
name: client.name,
indieAuthClients.value = indieAuthClients.value.concat(clients.map(client => ({
...client,
redirectUris: client.redirectUris.join('\n'),
createdAt: client.createdAt,
})));
indieAuthTimestamp.value = Date.now();
indieAuthHasMore.value = clients.length === 10;
indieAuthOffset.value += clients.length;
});
@ -302,7 +372,9 @@ function indieAuthDelete(client) {
}).then(({ canceled }) => {
if (canceled) return;
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) {
await misskeyApi('admin/indie-auth/update', params);
await os.apiWithDialog('admin/indie-auth/update', params).then(() => {
indieAuthFetch(true);
});
} 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(() => []);

View 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>

View File

@ -264,6 +264,9 @@ const routes: RouteDef[] = [{
}, {
path: '/oauth/authorize',
component: page(() => import('@/pages/oauth.vue')),
}, {
path: '/sso/:kind/:serviceId',
component: page(() => import('@/pages/sso.vue')),
}, {
path: '/tags/:tag',
component: page(() => import('@/pages/tag.vue')),

View File

@ -26,10 +26,10 @@
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/matter-js": "0.19.6",
"@types/node": "20.11.25",
"@types/node": "20.11.27",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"nodemon": "3.1.0",
"typescript": "5.4.2"

View File

@ -343,6 +343,24 @@ type AdminShowUsersRequest = operations['admin/show-users']['requestBody']['cont
// @public (undocumented)
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)
type AdminSuspendUserRequest = operations['admin/suspend-user']['requestBody']['content']['application/json'];
@ -1276,6 +1294,12 @@ declare namespace entities {
AdminRolesUpdateDefaultPoliciesRequest,
AdminRolesUsersRequest,
AdminRolesUsersResponse,
AdminSsoCreateRequest,
AdminSsoCreateResponse,
AdminSsoDeleteRequest,
AdminSsoListRequest,
AdminSsoListResponse,
AdminSsoUpdateRequest,
AnnouncementsRequest,
AnnouncementsResponse,
AntennasCreateRequest,
@ -2457,7 +2481,7 @@ type ModerationLog = {
});
// @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)
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;
// @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)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View File

@ -9,12 +9,12 @@
"devDependencies": {
"@misskey-dev/eslint-plugin": "^1.0.0",
"@readme/openapi-parser": "2.5.0",
"@types/node": "20.11.25",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@types/node": "20.11.27",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"openapi-types": "12.1.3",
"openapi-typescript": "6.7.4",
"openapi-typescript": "6.7.5",
"ts-case-convert": "2.0.7",
"tsx": "4.7.1",
"typescript": "5.4.2"

View File

@ -39,9 +39,9 @@
"@misskey-dev/eslint-plugin": "1.0.0",
"@swc/jest": "0.2.36",
"@types/jest": "29.5.12",
"@types/node": "20.11.25",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@types/node": "20.11.27",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"jest": "29.7.0",
"jest-fetch-mock": "3.0.3",

View File

@ -928,6 +928,50 @@ declare module '../api.js' {
credential?: string | null,
): 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.
*

View File

@ -111,6 +111,12 @@ import type {
AdminRolesUpdateDefaultPoliciesRequest,
AdminRolesUsersRequest,
AdminRolesUsersResponse,
AdminSsoCreateRequest,
AdminSsoCreateResponse,
AdminSsoDeleteRequest,
AdminSsoListRequest,
AdminSsoListResponse,
AdminSsoUpdateRequest,
AnnouncementsRequest,
AnnouncementsResponse,
AntennasCreateRequest,
@ -653,6 +659,10 @@ export type Endpoints = {
'admin/roles/unassign': { req: AdminRolesUnassignRequest; res: EmptyResponse };
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
'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 };
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };

View File

@ -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 AdminRolesUsersRequest = operations['admin/roles/users']['requestBody']['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 AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
export type AntennasCreateRequest = operations['antennas/create']['requestBody']['content']['application/json'];

View File

@ -769,6 +769,42 @@ export type paths = {
*/
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
@ -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
* @description No description provided.

View File

@ -89,6 +89,8 @@ export const permissions = [
'write:admin:promo',
'write:admin:drive',
'read:admin:drive',
'write:admin:sso',
'read:admin:sso',
'write:admin:ad',
'read:admin:ad',
'write:invite-codes',
@ -136,6 +138,9 @@ export const moderationLogTypes = [
'createIndieAuthClient',
'updateIndieAuthClient',
'deleteIndieAuthClient',
'createSSOServiceProvider',
'updateSSOServiceProvider',
'deleteSSOServiceProvider',
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
@ -321,6 +326,19 @@ export type ModerationLogPayloads = {
clientId: string;
client: any;
};
createSSOServiceProvider: {
serviceId: string;
service: any;
};
updateSSOServiceProvider: {
serviceId: string;
before: any;
after: any;
};
deleteSSOServiceProvider: {
serviceId: string;
service: any;
};
createAvatarDecoration: {
avatarDecorationId: string;
avatarDecoration: any;

View File

@ -25,9 +25,9 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@types/node": "20.11.25",
"@typescript-eslint/eslint-plugin": "7.1.1",
"@typescript-eslint/parser": "7.1.1",
"@types/node": "20.11.27",
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"nodemon": "3.1.0",
"typescript": "5.4.2"

View File

@ -15,8 +15,8 @@
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "1.0.0",
"@typescript-eslint/parser": "7.1.1",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"@types/serviceworker": "0.0.84",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"nodemon": "3.1.0",

File diff suppressed because it is too large Load Diff