From 8c1db331e766f87d6d489f5fd2807b573b0e2ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=A1=E3=82=83=E3=81=A8=E3=83=BC?= =?UTF-8?q?=E3=81=AB=E3=82=85?= <17376330+u1-liquid@users.noreply.github.com> Date: Fri, 15 Mar 2024 01:30:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(SSO):=20JWT=E3=82=84SAML=E3=81=A7=E3=81=AE?= =?UTF-8?q?Single=20Sign-On=E3=81=AE=E5=AE=9F=E8=A3=85=20(MisskeyIO#519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +- .../1707697398681-indie-auth-client.js | 5 - .../migration/1710416761960-single-sign-on.js | 15 + packages/backend/package.json | 42 +- .../@types/samlify-xsd-schema-validator.d.ts | 1 + packages/backend/src/di-symbols.ts | 1 + packages/backend/src/logger.ts | 24 +- .../backend/src/models/RepositoryModule.ts | 9 + .../src/models/SingleSignOnServiceProvider.ts | 76 + packages/backend/src/models/_.ts | 3 + packages/backend/src/postgres.ts | 2 + packages/backend/src/server/ServerModule.ts | 4 + packages/backend/src/server/ServerService.ts | 7 + .../backend/src/server/api/EndpointsModule.ts | 16 + packages/backend/src/server/api/endpoints.ts | 8 + .../api/endpoints/admin/indie-auth/create.ts | 7 +- .../api/endpoints/admin/indie-auth/delete.ts | 5 - .../api/endpoints/admin/indie-auth/list.ts | 5 - .../api/endpoints/admin/indie-auth/update.ts | 7 +- .../server/api/endpoints/admin/sso/create.ts | 159 + .../server/api/endpoints/admin/sso/delete.ts | 53 + .../server/api/endpoints/admin/sso/list.ts | 111 + .../server/api/endpoints/admin/sso/update.ts | 86 + .../src/server/oauth/OAuth2ProviderService.ts | 11 +- .../server/sso/JWTIdentifyProviderService.ts | 375 ++ .../server/sso/SAMLIdentifyProviderService.ts | 654 ++++ .../src/server/web/views/sso-saml-post.pug | 21 + packages/backend/src/server/web/views/sso.pug | 6 + packages/backend/src/types.ts | 16 + packages/frontend/package.json | 66 +- .../frontend/src/pages/admin/security.vue | 179 +- packages/frontend/src/pages/sso.vue | 65 + packages/frontend/src/router/definition.ts | 3 + packages/misskey-bubble-game/package.json | 6 +- packages/misskey-js/etc/misskey-js.api.md | 28 +- packages/misskey-js/generator/package.json | 8 +- packages/misskey-js/package.json | 6 +- .../misskey-js/src/autogen/apiClientJSDoc.ts | 44 + packages/misskey-js/src/autogen/endpoint.ts | 10 + packages/misskey-js/src/autogen/entities.ts | 6 + packages/misskey-js/src/autogen/types.ts | 302 ++ packages/misskey-js/src/consts.ts | 18 + packages/misskey-reversi/package.json | 6 +- packages/sw/package.json | 4 +- pnpm-lock.yaml | 3333 +++++++++-------- 45 files changed, 4094 insertions(+), 1725 deletions(-) create mode 100644 packages/backend/migration/1710416761960-single-sign-on.js create mode 100644 packages/backend/src/@types/samlify-xsd-schema-validator.d.ts create mode 100644 packages/backend/src/models/SingleSignOnServiceProvider.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/sso/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/sso/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/sso/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/sso/update.ts create mode 100644 packages/backend/src/server/sso/JWTIdentifyProviderService.ts create mode 100644 packages/backend/src/server/sso/SAMLIdentifyProviderService.ts create mode 100644 packages/backend/src/server/web/views/sso-saml-post.pug create mode 100644 packages/backend/src/server/web/views/sso.pug create mode 100644 packages/frontend/src/pages/sso.vue diff --git a/package.json b/package.json index 1d64c4e342..be3a6a0ea0 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/backend/migration/1707697398681-indie-auth-client.js b/packages/backend/migration/1707697398681-indie-auth-client.js index 6071f5bf1c..fdbb646a35 100644 --- a/packages/backend/migration/1707697398681-indie-auth-client.js +++ b/packages/backend/migration/1707697398681-indie-auth-client.js @@ -1,8 +1,3 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - export class IndieAuthClient1707697398681 { name = 'IndieAuthClient1707697398681' diff --git a/packages/backend/migration/1710416761960-single-sign-on.js b/packages/backend/migration/1710416761960-single-sign-on.js new file mode 100644 index 0000000000..a24d3aab6e --- /dev/null +++ b/packages/backend/migration/1710416761960-single-sign-on.js @@ -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"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index e2a530b1e6..e16d2bbbe3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/@types/samlify-xsd-schema-validator.d.ts b/packages/backend/src/@types/samlify-xsd-schema-validator.d.ts new file mode 100644 index 0000000000..19d7cb11bb --- /dev/null +++ b/packages/backend/src/@types/samlify-xsd-schema-validator.d.ts @@ -0,0 +1 @@ +declare module '@authenio/samlify-xsd-schema-validator'; diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index ca19c59eea..0ba0b86c9d 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -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'), diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index 08f18b066e..12fec8bbb1 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -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 | 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 | 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 { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 6c5c166735..a610945002 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -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, diff --git a/packages/backend/src/models/SingleSignOnServiceProvider.ts b/packages/backend/src/models/SingleSignOnServiceProvider.ts new file mode 100644 index 0000000000..c08db01cb1 --- /dev/null +++ b/packages/backend/src/models/SingleSignOnServiceProvider.ts @@ -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; +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index b5a9bb09d2..ca1410c247 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -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; export type RegistryItemsRepository = Repository; export type RelaysRepository = Repository; export type SigninsRepository = Repository; +export type SingleSignOnServiceProviderRepository = Repository; export type SwSubscriptionsRepository = Repository; export type UsedUsernamesRepository = Repository; export type UsersRepository = Repository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 67d33526a4..dfa5d86e12 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -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, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 52455d0063..aafbf8060d 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -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, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index e9f8e710ca..9cc7de91aa 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -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; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 1d5825c3f7..b29717f341 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 55657314f7..f39bae4f0f 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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], diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts index 514664867f..cfdda2231d 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/create.ts @@ -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 { // 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 })); diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts index 9b2d4908c0..681884af75 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/delete.ts @@ -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'; diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts index b524e516bf..7f92577e95 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/list.ts @@ -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'; diff --git a/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts b/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts index 34ae3bbc54..5a913a8a6f 100644 --- a/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/indie-auth/update.ts @@ -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 { // 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, }); diff --git a/packages/backend/src/server/api/endpoints/admin/sso/create.ts b/packages/backend/src/server/api/endpoints/admin/sso/create.ts new file mode 100644 index 0000000000..dc71bc4ac6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/sso/create.ts @@ -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 { // 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, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/sso/delete.ts b/packages/backend/src/server/api/endpoints/admin/sso/delete.ts new file mode 100644 index 0000000000..dc95e717ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/sso/delete.ts @@ -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 { // 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, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/sso/list.ts b/packages/backend/src/server/api/endpoints/admin/sso/list.ts new file mode 100644 index 0000000000..b67ffec998 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/sso/list.ts @@ -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 { // 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, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/sso/update.ts b/packages/backend/src/server/api/endpoints/admin/sso/update.ts new file mode 100644 index 0000000000..d186e28641 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/sso/update.ts @@ -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 { // 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, + }); + }); + } +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index 6f7e1a5378..453ed60a87 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -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 }; diff --git a/packages/backend/src/server/sso/JWTIdentifyProviderService.ts b/packages/backend/src/server/sso/JWTIdentifyProviderService.ts new file mode 100644 index 0000000000..248fe94f3a --- /dev/null +++ b/packages/backend/src/server/sso/JWTIdentifyProviderService.ts @@ -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 { + 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, + ); + 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 { + 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; + } + }); + } +} diff --git a/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts new file mode 100644 index 0000000000..87be0af298 --- /dev/null +++ b/packages/backend/src/server/sso/SAMLIdentifyProviderService.ts @@ -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 { + 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 { + 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 { + 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, + ); + 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}`); + } + }); + } +} diff --git a/packages/backend/src/server/web/views/sso-saml-post.pug b/packages/backend/src/server/web/views/sso-saml-post.pug new file mode 100644 index 0000000000..a3cf7e2391 --- /dev/null +++ b/packages/backend/src/server/web/views/sso-saml-post.pug @@ -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(); diff --git a/packages/backend/src/server/web/views/sso.pug b/packages/backend/src/server/web/views/sso.pug new file mode 100644 index 0000000000..bc12481278 --- /dev/null +++ b/packages/backend/src/server/web/views/sso.pug @@ -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) diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 76a82fdca7..81529d324e 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -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; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 5bba06858f..92f5fec117 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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" } } diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 614a01db01..5455f25a38 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -137,7 +137,7 @@ SPDX-License-Identifier: AGPL-3.0-only
New - +