feat(SSO): JWTやSAMLでのSingle Sign-Onの実装 (MisskeyIO#519)
This commit is contained in:
parent
d300a6829f
commit
8c1db331e7
45 changed files with 4094 additions and 1725 deletions
1
packages/backend/src/@types/samlify-xsd-schema-validator.d.ts
vendored
Normal file
1
packages/backend/src/@types/samlify-xsd-schema-validator.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module '@authenio/samlify-xsd-schema-validator';
|
||||
|
|
@ -55,6 +55,7 @@ export const DI = {
|
|||
authSessionsRepository: Symbol('authSessionsRepository'),
|
||||
accessTokensRepository: Symbol('accessTokensRepository'),
|
||||
signinsRepository: Symbol('signinsRepository'),
|
||||
singleSignOnServiceProviderRepository: Symbol('singleSignOnServiceProviderRepository'),
|
||||
pagesRepository: Symbol('pagesRepository'),
|
||||
pageLikesRepository: Symbol('pageLikesRepository'),
|
||||
galleryPostsRepository: Symbol('galleryPostsRepository'),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export default class Logger {
|
|||
|
||||
this.logger = pino({
|
||||
name: this.domain,
|
||||
serializers: {
|
||||
...pino.stdSerializers,
|
||||
err: pino.stdSerializers.errWithCause,
|
||||
},
|
||||
level: envOption.verbose ? 'debug' : 'info',
|
||||
depthLimit: 8,
|
||||
edgeLimit: 128,
|
||||
|
|
@ -63,17 +67,19 @@ export default class Logger {
|
|||
|
||||
@bindThis
|
||||
public error(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context === null) context = undefined;
|
||||
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
|
||||
|
||||
if (x instanceof Error) {
|
||||
context = context ?? {};
|
||||
context.error = x;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||
|
||||
if (important) this.logger.fatal({ context, important }, x.toString());
|
||||
else this.logger.error({ context, important }, x.toString());
|
||||
} else if (typeof x === 'object') {
|
||||
context = context ?? {};
|
||||
context.error = context.error ?? x;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||
|
||||
if (important) this.logger.fatal({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||
else this.logger.error({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||
|
|
@ -85,16 +91,18 @@ export default class Logger {
|
|||
|
||||
@bindThis
|
||||
public warn(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context === null) context = undefined;
|
||||
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
|
||||
|
||||
if (x instanceof Error) {
|
||||
context = context ?? {};
|
||||
context.error = x;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||
|
||||
this.logger.warn({ context, important }, x.toString());
|
||||
} else if (typeof x === 'object') {
|
||||
context = context ?? {};
|
||||
context.error = context.error ?? x;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
|
||||
|
||||
this.logger.warn({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
76
packages/backend/src/models/SingleSignOnServiceProvider.ts
Normal file
76
packages/backend/src/models/SingleSignOnServiceProvider.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Column, Index } from 'typeorm';
|
||||
|
||||
@Entity('sso_service_provider')
|
||||
export class MiSingleSignOnServiceProvider {
|
||||
@PrimaryColumn('varchar', {
|
||||
length: 36,
|
||||
})
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public name: string | null;
|
||||
|
||||
@Column('enum', {
|
||||
enum: ['saml', 'jwt'],
|
||||
nullable: false,
|
||||
})
|
||||
public type: 'saml' | 'jwt';
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
})
|
||||
public issuer: string;
|
||||
|
||||
@Column('varchar', {
|
||||
array: true, length: 512, default: '{}',
|
||||
})
|
||||
public audience: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
})
|
||||
public acsUrl: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 4096,
|
||||
})
|
||||
public publicKey: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 4096, nullable: true,
|
||||
})
|
||||
public privateKey: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 100,
|
||||
})
|
||||
public signatureAlgorithm: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 100, nullable: true,
|
||||
})
|
||||
public cipherAlgorithm: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public wantAuthnRequestsSigned: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public wantAssertionsSigned: boolean;
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
|
|||
import { MiRegistryItem } from '@/models/RegistryItem.js';
|
||||
import { MiRelay } from '@/models/Relay.js';
|
||||
import { MiSignin } from '@/models/Signin.js';
|
||||
import { MiSingleSignOnServiceProvider } from '@/models/SingleSignOnServiceProvider.js';
|
||||
import { MiSwSubscription } from '@/models/SwSubscription.js';
|
||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
|
|
@ -121,6 +122,7 @@ export {
|
|||
MiRegistryItem,
|
||||
MiRelay,
|
||||
MiSignin,
|
||||
MiSingleSignOnServiceProvider,
|
||||
MiSwSubscription,
|
||||
MiUsedUsername,
|
||||
MiUser,
|
||||
|
|
@ -192,6 +194,7 @@ export type RegistrationTicketsRepository = Repository<MiRegistrationTicket>;
|
|||
export type RegistryItemsRepository = Repository<MiRegistryItem>;
|
||||
export type RelaysRepository = Repository<MiRelay>;
|
||||
export type SigninsRepository = Repository<MiSignin>;
|
||||
export type SingleSignOnServiceProviderRepository = Repository<MiSingleSignOnServiceProvider>;
|
||||
export type SwSubscriptionsRepository = Repository<MiSwSubscription>;
|
||||
export type UsedUsernamesRepository = Repository<MiUsedUsername>;
|
||||
export type UsersRepository = Repository<MiUser>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||
|
|
@ -70,7 +65,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const indieAuthClient = await this.indieAuthClientsRepository.insert({
|
||||
id: ps.id,
|
||||
createdAt: new Date(),
|
||||
name: ps.name,
|
||||
name: ps.name ? ps.name : null,
|
||||
redirectUris: ps.redirectUris,
|
||||
}).then(r => this.indieAuthClientsRepository.findOneByOrFail({ id: r.identifiers[0].id }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { IndieAuthClientsRepository } from '@/models/_.js';
|
||||
|
|
@ -53,7 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (client == null) throw new ApiError(meta.errors.noSuchIndieAuthClient);
|
||||
|
||||
await this.indieAuthClientsRepository.update(client.id, {
|
||||
name: ps.name,
|
||||
name: ps.name !== '' ? ps.name : null,
|
||||
redirectUris: ps.redirectUris,
|
||||
});
|
||||
|
||||
|
|
|
|||
159
packages/backend/src/server/api/endpoints/admin/sso/create.ts
Normal file
159
packages/backend/src/server/api/endpoints/admin/sso/create.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as jose from 'jose';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:sso',
|
||||
|
||||
errors: {
|
||||
invalidParamSamlUseCertificate: {
|
||||
message: 'SAML service provider must use certificate.',
|
||||
code: 'INVALID_PARAM',
|
||||
id: 'bb97e559-f23c-4d6a-9e4e-eb5db1f467f9',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['saml', 'jwt'],
|
||||
},
|
||||
issuer: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
audience: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: { type: 'string', nullable: false },
|
||||
},
|
||||
acsUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
publicKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
signatureAlgorithm: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
cipherAlgorithm: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
wantAuthnRequestsSigned: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
wantAssertionsSigned: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true },
|
||||
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
|
||||
issuer: { type: 'string', nullable: false },
|
||||
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
|
||||
acsUrl: { type: 'string', nullable: false },
|
||||
signatureAlgorithm: { type: 'string', nullable: false },
|
||||
cipherAlgorithm: { type: 'string', nullable: true },
|
||||
wantAuthnRequestsSigned: { type: 'boolean', nullable: false, default: false },
|
||||
wantAssertionsSigned: { type: 'boolean', nullable: false, default: true },
|
||||
useCertificate: { type: 'boolean', nullable: false, default: true },
|
||||
secret: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['type', 'issuer', 'acsUrl', 'signatureAlgorithm', 'useCertificate'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (ps.type === 'saml' && ps.useCertificate === false) {
|
||||
throw new ApiError(meta.errors.invalidParamSamlUseCertificate);
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = ps.useCertificate
|
||||
? await jose.generateKeyPair(ps.signatureAlgorithm).then(async keypair => ({
|
||||
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
|
||||
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
|
||||
}))
|
||||
: { publicKey: ps.secret ?? randomUUID(), privateKey: null };
|
||||
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.insert({
|
||||
id: randomUUID(),
|
||||
createdAt: new Date(),
|
||||
name: ps.name ? ps.name : null,
|
||||
type: ps.type,
|
||||
issuer: ps.issuer,
|
||||
audience: ps.audience,
|
||||
acsUrl: ps.acsUrl,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
signatureAlgorithm: ps.signatureAlgorithm,
|
||||
cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null,
|
||||
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
|
||||
wantAssertionsSigned: ps.wantAssertionsSigned,
|
||||
}).then(r => this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: r.identifiers[0].id }));
|
||||
|
||||
this.moderationLogService.log(me, 'createSSOServiceProvider', {
|
||||
serviceId: ssoServiceProvider.id,
|
||||
service: ssoServiceProvider,
|
||||
});
|
||||
|
||||
return {
|
||||
id: ssoServiceProvider.id,
|
||||
createdAt: ssoServiceProvider.createdAt.toISOString(),
|
||||
name: ssoServiceProvider.name,
|
||||
type: ssoServiceProvider.type,
|
||||
issuer: ssoServiceProvider.issuer,
|
||||
audience: ssoServiceProvider.audience,
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
publicKey: ssoServiceProvider.publicKey,
|
||||
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
|
||||
cipherAlgorithm: ssoServiceProvider.cipherAlgorithm,
|
||||
wantAuthnRequestsSigned: ssoServiceProvider.wantAuthnRequestsSigned,
|
||||
wantAssertionsSigned: ssoServiceProvider.wantAssertionsSigned,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:sso',
|
||||
|
||||
errors: {
|
||||
noSuchSingleSignOnServiceProvider: {
|
||||
message: 'No such SSO Service Provider',
|
||||
code: 'NO_SUCH_SSO_SP',
|
||||
id: 'ece541d3-6c41-4fc3-a514-fa762b96704a',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
|
||||
|
||||
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
|
||||
|
||||
await this.singleSignOnServiceProviderRepository.delete(service.id);
|
||||
|
||||
this.moderationLogService.log(me, 'deleteSSOServiceProvider', {
|
||||
serviceId: service.id,
|
||||
service: service,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
111
packages/backend/src/server/api/endpoints/admin/sso/list.ts
Normal file
111
packages/backend/src/server/api/endpoints/admin/sso/list.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:sso',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['saml', 'jwt'],
|
||||
},
|
||||
issuer: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
audience: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: { type: 'string', nullable: false },
|
||||
},
|
||||
acsUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
publicKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
signatureAlgorithm: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
cipherAlgorithm: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
},
|
||||
wantAuthnRequestsSigned: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
wantAssertionsSigned: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.singleSignOnServiceProviderRepository.createQueryBuilder('service');
|
||||
const services = await query.offset(ps.offset).limit(ps.limit).getMany();
|
||||
|
||||
return services.map(service => ({
|
||||
id: service.id,
|
||||
createdAt: service.createdAt.toISOString(),
|
||||
name: service.name,
|
||||
type: service.type,
|
||||
issuer: service.issuer,
|
||||
audience: service.audience,
|
||||
acsUrl: service.acsUrl,
|
||||
publicKey: service.publicKey,
|
||||
signatureAlgorithm: service.signatureAlgorithm,
|
||||
cipherAlgorithm: service.cipherAlgorithm,
|
||||
wantAuthnRequestsSigned: service.wantAuthnRequestsSigned,
|
||||
wantAssertionsSigned: service.wantAssertionsSigned,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import * as jose from 'jose';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:sso',
|
||||
|
||||
errors: {
|
||||
noSuchSingleSignOnServiceProvider: {
|
||||
message: 'No such SSO Service Provider',
|
||||
code: 'NO_SUCH_SSO_SP',
|
||||
id: '2f481db0-23f5-4380-8cb8-704169ffb25b',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
issuer: { type: 'string' },
|
||||
audience: { type: 'array', items: { type: 'string', nullable: false } },
|
||||
acsUrl: { type: 'string' },
|
||||
signatureAlgorithm: { type: 'string' },
|
||||
cipherAlgorithm: { type: 'string' },
|
||||
wantAuthnRequestsSigned: { type: 'boolean' },
|
||||
wantAssertionsSigned: { type: 'boolean' },
|
||||
regenerateCertificate: { type: 'boolean' },
|
||||
secret: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
|
||||
|
||||
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
|
||||
|
||||
const alg = ps.signatureAlgorithm ? ps.signatureAlgorithm : service.signatureAlgorithm;
|
||||
const { publicKey, privateKey } = ps.regenerateCertificate
|
||||
? await jose.generateKeyPair(alg).then(async keypair => ({
|
||||
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
|
||||
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
|
||||
}))
|
||||
: { publicKey: ps.secret ?? undefined, privateKey: undefined };
|
||||
|
||||
await this.singleSignOnServiceProviderRepository.update(service.id, {
|
||||
name: ps.name !== '' ? ps.name : null,
|
||||
issuer: ps.issuer,
|
||||
audience: ps.audience,
|
||||
acsUrl: ps.acsUrl,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
signatureAlgorithm: ps.signatureAlgorithm,
|
||||
cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null,
|
||||
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
|
||||
wantAssertionsSigned: ps.wantAssertionsSigned,
|
||||
});
|
||||
|
||||
const updatedService = await this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: service.id });
|
||||
|
||||
this.moderationLogService.log(me, 'updateSSOServiceProvider', {
|
||||
serviceId: service.id,
|
||||
before: service,
|
||||
after: updatedService,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ import type {
|
|||
AccessTokensRepository,
|
||||
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 };
|
||||
|
|
|
|||
375
packages/backend/src/server/sso/JWTIdentifyProviderService.ts
Normal file
375
packages/backend/src/server/sso/JWTIdentifyProviderService.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import pug from 'pug';
|
||||
import fastifyView from '@fastify/view';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifyFormbody from '@fastify/formbody';
|
||||
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
|
||||
import * as jose from 'jose';
|
||||
import { JWTPayload } from 'jose';
|
||||
import Logger from '@/logger.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type {
|
||||
SingleSignOnServiceProviderRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class JWTIdentifyProviderService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private cacheService: CacheService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.#logger = this.loggerService.getLogger('sso:jwt');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||
fastify.register(fastifyFormbody);
|
||||
fastify.register(fastifyCors);
|
||||
fastify.register(fastifyView, {
|
||||
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||
engine: { pug },
|
||||
defaultContext: {
|
||||
version: this.config.version,
|
||||
config: this.config,
|
||||
},
|
||||
});
|
||||
|
||||
fastify.all<{
|
||||
Params: { serviceId: string };
|
||||
Querystring?: { return_to?: string };
|
||||
Body?: { return_to?: string };
|
||||
}>('/:serviceId', async (request, reply) => {
|
||||
const serviceId = request.params.serviceId;
|
||||
const returnTo = request.query?.return_to ?? request.body?.return_to;
|
||||
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: 'c6aafae6-e8b9-420c-a87a-6ac08402165b',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const transactionId = randomUUID();
|
||||
await this.redisClient.set(
|
||||
`sso:jwt:transaction:${transactionId}`,
|
||||
JSON.stringify({
|
||||
serviceId: serviceId,
|
||||
returnTo: returnTo,
|
||||
}),
|
||||
'EX',
|
||||
60 * 5,
|
||||
);
|
||||
|
||||
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return await reply.view('sso', {
|
||||
transactionId: transactionId,
|
||||
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
|
||||
kind: 'jwt',
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post<{
|
||||
Body: { transaction_id: string; login_token: string; cancel?: string };
|
||||
}>('/authorize', async (request, reply) => {
|
||||
const transactionId = request.body.transaction_id;
|
||||
const token = request.body.login_token;
|
||||
const cancel = !!request.body.cancel;
|
||||
|
||||
if (cancel) {
|
||||
reply.redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = await this.redisClient.get(`sso:jwt:transaction:${transactionId}`);
|
||||
if (!transaction) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid transaction id',
|
||||
code: 'INVALID_TRANSACTION_ID',
|
||||
id: '91fa6511-0b33-47d6-bd01-b420d80fcd6a',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { serviceId, returnTo } = JSON.parse(transaction);
|
||||
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: 'c038610c-4c11-40ce-9371-131d5720f511',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
reply.status(401).send({
|
||||
error: {
|
||||
message: 'No login token',
|
||||
code: 'NO_LOGIN_TOKEN',
|
||||
id: '399e756c-35cd-459c-a7ba-8cc12eb39eef',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
|
||||
token,
|
||||
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
|
||||
);
|
||||
if (!user) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid login token',
|
||||
code: 'INVALID_LOGIN_TOKEN',
|
||||
id: '3b92ee31-9215-447a-805f-df8f15ffb8b2',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
const isAdministrator = await this.roleService.isAdministrator(user);
|
||||
const isModerator = await this.roleService.isModerator(user);
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
|
||||
const payload: JWTPayload = {
|
||||
name: user.name,
|
||||
preferred_username: user.username,
|
||||
profile: `${this.config.url}/@${user.username}`,
|
||||
picture: user.avatarUrl,
|
||||
email: profile.email,
|
||||
email_verified: profile.emailVerified,
|
||||
mfa_enabled: profile.twoFactorEnabled,
|
||||
updated_at: (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
|
||||
admin: isAdministrator,
|
||||
moderator: isModerator,
|
||||
roles: roles.filter(r => r.isPublic).map(r => r.id),
|
||||
};
|
||||
|
||||
try {
|
||||
if (ssoServiceProvider.cipherAlgorithm) {
|
||||
const key = ssoServiceProvider.publicKey.startsWith('{')
|
||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||
|
||||
const jwt = await new jose.EncryptJWT(payload)
|
||||
.setProtectedHeader({
|
||||
alg: ssoServiceProvider.signatureAlgorithm,
|
||||
enc: ssoServiceProvider.cipherAlgorithm,
|
||||
})
|
||||
.setIssuer(ssoServiceProvider.issuer)
|
||||
.setAudience(ssoServiceProvider.audience)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('10m')
|
||||
.setJti(randomUUID())
|
||||
.setSubject(user.id)
|
||||
.encrypt(key);
|
||||
|
||||
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
|
||||
userId: user.id,
|
||||
ssoServiceProvider: ssoServiceProvider.id,
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
returnTo,
|
||||
});
|
||||
|
||||
if (returnTo) {
|
||||
reply.redirect(
|
||||
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
reply.redirect(
|
||||
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const key = ssoServiceProvider.privateKey
|
||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||
|
||||
const jwt = await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
|
||||
.setIssuer(ssoServiceProvider.issuer)
|
||||
.setAudience(ssoServiceProvider.audience)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('10m')
|
||||
.setJti(randomUUID())
|
||||
.setSubject(user.id)
|
||||
.sign(key);
|
||||
|
||||
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
|
||||
userId: user.id,
|
||||
ssoServiceProvider: ssoServiceProvider.id,
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
returnTo,
|
||||
});
|
||||
|
||||
if (returnTo) {
|
||||
reply.redirect(
|
||||
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
reply.redirect(
|
||||
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.#logger.error('Failed to create JWT', { error: err });
|
||||
const traceableError = err as Error & { code?: string };
|
||||
|
||||
if (traceableError.code) {
|
||||
reply.status(500).send({
|
||||
error: {
|
||||
message: traceableError.message,
|
||||
code: traceableError.code,
|
||||
id: 'a436fa15-20ca-4269-ac4d-ee162fe1f3b0',
|
||||
kind: 'server',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(500).send({
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
id: 'fe1c597c-a515-46a1-860b-bd316b11aff9',
|
||||
kind: 'server',
|
||||
},
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
await this.redisClient.del(`sso:jwt:transaction:${transactionId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createApiServer(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||
fastify.register(fastifyFormbody);
|
||||
fastify.register(fastifyCors);
|
||||
|
||||
fastify.post<{
|
||||
Params: { serviceId: string };
|
||||
Body: { jwt: string };
|
||||
}>('/verify/:serviceId', async (request, reply) => {
|
||||
const serviceId = request.params.serviceId;
|
||||
const jwt = request.body.jwt;
|
||||
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: '077e0930-88c1-4f25-bd4e-4da8e34f735b',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (ssoServiceProvider.cipherAlgorithm) {
|
||||
const key = ssoServiceProvider.privateKey
|
||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||
|
||||
const { payload } = await jose.jwtDecrypt(jwt, key, {
|
||||
issuer: ssoServiceProvider.issuer,
|
||||
audience: ssoServiceProvider.audience,
|
||||
});
|
||||
|
||||
reply.status(200).send({ payload });
|
||||
return;
|
||||
} else {
|
||||
const key = ssoServiceProvider.publicKey.startsWith('{')
|
||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||
|
||||
const { payload } = await jose.jwtVerify(jwt, key, {
|
||||
issuer: ssoServiceProvider.issuer,
|
||||
audience: ssoServiceProvider.audience,
|
||||
});
|
||||
|
||||
reply.status(200).send({ payload });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.#logger.error('Failed to verify JWT', { error: err });
|
||||
const traceableError = err as Error & { code?: string };
|
||||
|
||||
if (traceableError.code) {
|
||||
reply.status(400).send({
|
||||
error: {
|
||||
message: traceableError.message,
|
||||
code: traceableError.code,
|
||||
id: '843421cf-3ab3-4b1f-ade4-5d5ce1efb6be',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(400).send({
|
||||
error: {
|
||||
message: 'Invalid JWT',
|
||||
code: 'INVALID_JWT',
|
||||
id: '39075dbb-03eb-485f-8ee1-f16b625bcc4d',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
654
packages/backend/src/server/sso/SAMLIdentifyProviderService.ts
Normal file
654
packages/backend/src/server/sso/SAMLIdentifyProviderService.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as jose from 'jose';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as saml from 'samlify';
|
||||
import * as validator from '@authenio/samlify-node-xmllint';
|
||||
import fastifyView from '@fastify/view';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifyFormbody from '@fastify/formbody';
|
||||
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
|
||||
import pug from 'pug';
|
||||
import xmlbuilder from 'xmlbuilder';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import Logger from '@/logger.js';
|
||||
import type {
|
||||
MiSingleSignOnServiceProvider,
|
||||
SingleSignOnServiceProviderRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class SAMLIdentifyProviderService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.singleSignOnServiceProviderRepository)
|
||||
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private cacheService: CacheService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.#logger = this.loggerService.getLogger('sso:saml');
|
||||
saml.setSchemaValidator(validator);
|
||||
}
|
||||
|
||||
public async createIdPMetadataXml(
|
||||
provider: MiSingleSignOnServiceProvider,
|
||||
): Promise<string> {
|
||||
const today = new Date();
|
||||
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
|
||||
|
||||
const nodes = {
|
||||
'md:EntityDescriptor': {
|
||||
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
||||
'@entityID': provider.issuer,
|
||||
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
|
||||
'md:IDPSSODescriptor': {
|
||||
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
||||
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'md:KeyDescriptor': {
|
||||
'@use': 'signing',
|
||||
'ds:KeyInfo': {
|
||||
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||
'ds:X509Data': {
|
||||
'ds:X509Certificate': {
|
||||
'#text': publicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'md:NameIDFormat': {
|
||||
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
},
|
||||
'md:SingleSignOnService': [
|
||||
{
|
||||
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
|
||||
},
|
||||
{
|
||||
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return xmlbuilder
|
||||
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||
.end({ pretty: true });
|
||||
}
|
||||
|
||||
public async createSPMetadataXml(
|
||||
provider: MiSingleSignOnServiceProvider,
|
||||
): Promise<string> {
|
||||
const today = new Date();
|
||||
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
|
||||
|
||||
const keyDescriptor: unknown[] = [
|
||||
{
|
||||
'@use': 'signing',
|
||||
'ds:KeyInfo': {
|
||||
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||
'ds:X509Data': {
|
||||
'ds:X509Certificate': {
|
||||
'#text': publicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (provider.cipherAlgorithm) {
|
||||
keyDescriptor.push({
|
||||
'@use': 'encryption',
|
||||
'ds:KeyInfo': {
|
||||
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
|
||||
'ds:X509Data': {
|
||||
'ds:X509Certificate': {
|
||||
'#text': publicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
'md:EncryptionMethod': {
|
||||
'@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const nodes = {
|
||||
'md:EntityDescriptor': {
|
||||
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
||||
'@entityID': provider.issuer,
|
||||
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
|
||||
'md:SPSSODescriptor': {
|
||||
'@AuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
||||
'@WantAssertionsSigned': provider.wantAssertionsSigned,
|
||||
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'md:KeyDescriptor': keyDescriptor,
|
||||
'md:NameIDFormat': {
|
||||
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
},
|
||||
'md:AssertionConsumerService': {
|
||||
'@index': 1,
|
||||
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
'@Location': provider.acsUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return xmlbuilder
|
||||
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||
.end({ pretty: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @desc Alternative to lodash.get
|
||||
* @reference https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get
|
||||
* @param obj
|
||||
* @param path
|
||||
* @param defaultValue
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private get(obj: any, path: string, defaultValue: unknown) {
|
||||
return path
|
||||
.split('.')
|
||||
.reduce((a, c) => (a?.[c] ? a[c] : defaultValue || null), obj);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
|
||||
fastify.register(fastifyFormbody);
|
||||
fastify.register(fastifyCors);
|
||||
fastify.register(fastifyView, {
|
||||
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||
engine: { pug },
|
||||
defaultContext: {
|
||||
version: this.config.version,
|
||||
config: this.config,
|
||||
},
|
||||
});
|
||||
|
||||
fastify.all<{
|
||||
Params: { serviceId: string };
|
||||
Querystring?: { SAMLRequest?: string; RelayState?: string };
|
||||
Body?: { SAMLRequest?: string; RelayState?: string };
|
||||
}>('/:serviceId', async (request, reply) => {
|
||||
const serviceId = request.params.serviceId;
|
||||
const binding = request.query?.SAMLRequest ? 'redirect' : 'post';
|
||||
const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest;
|
||||
const relayState = request.query?.RelayState ?? request.body?.RelayState;
|
||||
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml', privateKey: Not(IsNull()) });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: 'e2893d7e-df6f-44cf-8717-42234b8ac0ce',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!samlRequest) {
|
||||
reply.status(400).send({
|
||||
error: {
|
||||
message: 'No SAMLRequest',
|
||||
code: 'NO_SAML_REQUEST',
|
||||
id: 'c58bc7e3-f92e-4879-a6a9-7258a13bc491',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const idp = saml.IdentityProvider({
|
||||
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
|
||||
privateKey: await jose
|
||||
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
|
||||
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
|
||||
});
|
||||
|
||||
const sp = saml.ServiceProvider({
|
||||
metadata: await this.createSPMetadataXml(ssoServiceProvider),
|
||||
});
|
||||
|
||||
const parsed = await idp.parseLoginRequest(sp, binding, { query: request.query, body: request.body });
|
||||
this.#logger.info('Parsed SAML request', { saml: parsed });
|
||||
|
||||
const transactionId = randomUUID();
|
||||
await this.redisClient.set(
|
||||
`sso:saml:transaction:${transactionId}`,
|
||||
JSON.stringify({
|
||||
serviceId: serviceId,
|
||||
binding: binding,
|
||||
flowResult: parsed,
|
||||
relayState: relayState,
|
||||
}),
|
||||
'EX',
|
||||
60 * 5,
|
||||
);
|
||||
|
||||
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return await reply.view('sso', {
|
||||
transactionId: transactionId,
|
||||
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
|
||||
kind: 'saml',
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get<{ Params: { serviceId: string } }>(
|
||||
'/:serviceId/metadata',
|
||||
async (request, reply) => {
|
||||
const serviceId = request.params.serviceId;
|
||||
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: '8a6d72e1-3530-4ec0-9d4d-b105fdbb8a2d',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'application/xml');
|
||||
reply.send(await this.createIdPMetadataXml(ssoServiceProvider));
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post<{
|
||||
Body: { transaction_id: string; login_token: string; cancel?: string };
|
||||
}>('/authorize', async (request, reply) => {
|
||||
const transactionId = request.body.transaction_id;
|
||||
const token = request.body.login_token;
|
||||
const cancel = !!request.body.cancel;
|
||||
|
||||
if (cancel) {
|
||||
reply.redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`);
|
||||
if (!transaction) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid transaction id',
|
||||
code: 'INVALID_TRANSACTION_ID',
|
||||
id: 'cca6ea16-5f04-4d9e-9ef5-8a99bdef3a92',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction);
|
||||
|
||||
const ssoServiceProvider =
|
||||
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
||||
if (!ssoServiceProvider) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid SSO Service Provider id',
|
||||
code: 'INVALID_SSO_SP_ID',
|
||||
id: 'f644adfe-019a-478c-b5a9-897a2556f2b2',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
reply.status(401).send({
|
||||
error: {
|
||||
message: 'No login token',
|
||||
code: 'NO_LOGIN_TOKEN',
|
||||
id: 'cd96295e-0370-433d-a3de-421de4536b7f',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
|
||||
token,
|
||||
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
|
||||
);
|
||||
if (!user) {
|
||||
reply.status(403).send({
|
||||
error: {
|
||||
message: 'Invalid login token',
|
||||
code: 'INVALID_LOGIN_TOKEN',
|
||||
id: 'a002a4ed-0024-460f-8015-cc5e7c6cd0a7',
|
||||
kind: 'client',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
const isAdministrator = await this.roleService.isAdministrator(user);
|
||||
const isModerator = await this.roleService.isModerator(user);
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
|
||||
try {
|
||||
const idp = saml.IdentityProvider({
|
||||
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
|
||||
privateKey: await jose
|
||||
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
|
||||
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
|
||||
loginResponseTemplate: { context: 'ignored' },
|
||||
});
|
||||
|
||||
const sp = saml.ServiceProvider({
|
||||
metadata: await this.createSPMetadataXml(ssoServiceProvider),
|
||||
});
|
||||
|
||||
const samlResponse = await idp.createLoginResponse(
|
||||
sp,
|
||||
flowResult,
|
||||
binding,
|
||||
{},
|
||||
() => {
|
||||
const id = idp.entitySetting.generateID?.() ?? randomUUID();
|
||||
const assertionId = idp.entitySetting.generateID?.() ?? randomUUID();
|
||||
const nowTime = new Date();
|
||||
const fiveMinutesLaterTime = new Date(nowTime.getTime());
|
||||
fiveMinutesLaterTime.setMinutes(fiveMinutesLaterTime.getMinutes() + 5);
|
||||
const now = nowTime.toISOString();
|
||||
const fiveMinutesLater = fiveMinutesLaterTime.toISOString();
|
||||
|
||||
const nodes = {
|
||||
'samlp:Response': {
|
||||
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
||||
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
|
||||
'@ID': id,
|
||||
'@Version': '2.0',
|
||||
'@IssueInstant': now,
|
||||
'@Destination': ssoServiceProvider.acsUrl,
|
||||
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
|
||||
'saml:Issuer': {
|
||||
'#text': ssoServiceProvider.issuer,
|
||||
},
|
||||
'samlp:Status': {
|
||||
'samlp:StatusCode': {
|
||||
'@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success',
|
||||
},
|
||||
},
|
||||
'saml:Assertion': {
|
||||
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
'@xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
|
||||
'@ID': assertionId,
|
||||
'@Version': '2.0',
|
||||
'@IssueInstant': now,
|
||||
'saml:Issuer': {
|
||||
'#text': ssoServiceProvider.issuer,
|
||||
},
|
||||
'saml:Subject': {
|
||||
'saml:NameID': {
|
||||
'@Format':
|
||||
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
'#text': user.id,
|
||||
},
|
||||
'saml:SubjectConfirmation': {
|
||||
'@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer',
|
||||
'saml:SubjectConfirmationData': {
|
||||
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
|
||||
'@NotOnOrAfter': fiveMinutesLater,
|
||||
'@Recipient': ssoServiceProvider.acsUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
'saml:Conditions': {
|
||||
'@NotBefore': now,
|
||||
'@NotOnOrAfter': fiveMinutesLater,
|
||||
'saml:AudienceRestriction': {
|
||||
'saml:Audience': [
|
||||
{ '#text': ssoServiceProvider.issuer },
|
||||
...ssoServiceProvider.audience.map((audience) => ({
|
||||
'#text': audience,
|
||||
})),
|
||||
],
|
||||
},
|
||||
},
|
||||
'saml:AuthnStatement': {
|
||||
'@AuthnInstant': now,
|
||||
'@SessionIndex': assertionId,
|
||||
'@SessionNotOnOrAfter': fiveMinutesLater,
|
||||
'saml:AuthnContext': {
|
||||
'saml:AuthnContextClassRef': {
|
||||
'#text':
|
||||
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
|
||||
},
|
||||
},
|
||||
},
|
||||
'saml:AttributeStatement': {
|
||||
'saml:Attribute': [
|
||||
{
|
||||
'@Name': 'identityprovider',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': this.config.url,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'uid',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': user.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'displayname',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': user.name,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'name',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': user.username,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'preferred_username',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': user.username,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'profile',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': `${this.config.url}/@${user.username}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'picture',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': user.avatarUrl,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'mail',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': profile.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'email',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': profile.email,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'email_verified',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:boolean',
|
||||
'#text': profile.emailVerified,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'mfa_enabled',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:boolean',
|
||||
'#text': profile.twoFactorEnabled,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'updated_at',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:integer',
|
||||
'#text': (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'admin',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:boolean',
|
||||
'#text': isAdministrator,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'moderator',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': {
|
||||
'@xsi:type': 'xs:boolean',
|
||||
'#text': isModerator,
|
||||
},
|
||||
},
|
||||
{
|
||||
'@Name': 'roles',
|
||||
'@NameFormat':
|
||||
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
|
||||
'saml:AttributeValue': [
|
||||
...roles
|
||||
.filter((r) => r.isPublic)
|
||||
.map((r) => ({
|
||||
'@xsi:type': 'xs:string',
|
||||
'#text': r.id,
|
||||
})),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
context: xmlbuilder
|
||||
.create(nodes, { encoding: 'UTF-8', standalone: false })
|
||||
.end({ pretty: false }),
|
||||
};
|
||||
},
|
||||
undefined,
|
||||
relayState,
|
||||
);
|
||||
|
||||
this.#logger.info(`Rendering SAML response page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`, {
|
||||
userId: user.id,
|
||||
ssoServiceProvider: ssoServiceProvider.id,
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
relayState: relayState,
|
||||
});
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return await reply.view('sso-saml-post', {
|
||||
acsUrl: ssoServiceProvider.acsUrl,
|
||||
samlResponse: samlResponse,
|
||||
relyState: relayState ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
this.#logger.error('Failed to create SAML response', { error: err });
|
||||
const traceableError = err as Error & { code?: string };
|
||||
|
||||
if (traceableError.code) {
|
||||
reply.status(500).send({
|
||||
error: {
|
||||
message: traceableError.message,
|
||||
code: traceableError.code,
|
||||
id: 'a743ff78-8636-4b69-a54f-e3b395564f79',
|
||||
kind: 'server',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(500).send({
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
id: 'b83b7afd-adfc-4baf-8659-34623d639170',
|
||||
kind: 'server',
|
||||
},
|
||||
});
|
||||
return;
|
||||
} finally {
|
||||
await this.redisClient.del(`sso:saml:transaction:${transactionId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
21
packages/backend/src/server/web/views/sso-saml-post.pug
Normal file
21
packages/backend/src/server/web/views/sso-saml-post.pug
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
html
|
||||
body
|
||||
noscript: p
|
||||
| JavaScriptを有効にしてください
|
||||
br
|
||||
| Please turn on your JavaScript
|
||||
|
||||
p
|
||||
| Redirecting...
|
||||
|
||||
form(id='sso', method='post', action=action autocomplete='off')
|
||||
input(type='hidden', name='SAMLResponse', value=samlResponse)
|
||||
|
||||
if relayState !== null
|
||||
input(type='hidden', name='RelayState', value=relayState)
|
||||
|
||||
button(type='submit')
|
||||
| click here if you are not redirected.
|
||||
|
||||
script.
|
||||
document.forms[0].submit();
|
||||
6
packages/backend/src/server/web/views/sso.pug
Normal file
6
packages/backend/src/server/web/views/sso.pug
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
extends ./base
|
||||
|
||||
block meta
|
||||
meta(name='misskey:sso:transaction-id' content=transactionId)
|
||||
meta(name='misskey:sso:service-name' content=serviceName)
|
||||
meta(name='misskey:sso:kind' content=kind)
|
||||
|
|
@ -88,6 +88,9 @@ export const moderationLogTypes = [
|
|||
'createIndieAuthClient',
|
||||
'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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue