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 1d64c4e34..be3a6a0ea 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 6071f5bf1..fdbb646a3 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 000000000..a24d3aab6 --- /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 e2a530b1e..e16d2bbbe 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 000000000..19d7cb11b --- /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 ca19c59ee..0ba0b86c9 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 08f18b066..12fec8bbb 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 6c5c16673..a61094500 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 000000000..c08db01cb --- /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 b5a9bb09d..ca1410c24 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 67d33526a..dfa5d86e1 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 52455d006..aafbf8060 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 e9f8e710c..9cc7de91a 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 1d5825c3f..b29717f34 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 55657314f..f39bae4f0 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 514664867..cfdda2231 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 9b2d4908c..681884af7 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 b524e516b..7f92577e9 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 34ae3bbc5..5a913a8a6 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 000000000..dc71bc4ac --- /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 000000000..dc95e717a --- /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 000000000..b67ffec99 --- /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 000000000..d186e2864 --- /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 6f7e1a537..453ed60a8 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 000000000..248fe94f3 --- /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 000000000..87be0af29 --- /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 000000000..a3cf7e239 --- /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 000000000..bc1248127 --- /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 76a82fdca..81529d324 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 5bba06858..92f5fec11 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 614a01db0..5455f25a3 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 - +