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

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

View file

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

View file

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

View file

@ -36,6 +36,10 @@ export default class Logger {
this.logger = pino({
name: this.domain,
serializers: {
...pino.stdSerializers,
err: pino.stdSerializers.errWithCause,
},
level: envOption.verbose ? 'debug' : 'info',
depthLimit: 8,
edgeLimit: 128,
@ -63,17 +67,19 @@ export default class Logger {
@bindThis
public error(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う
// eslint-disable-next-line no-param-reassign
if (context === null) context = undefined;
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
if (x instanceof Error) {
context = context ?? {};
context.error = x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
if (important) this.logger.fatal({ context, important }, x.toString());
else this.logger.error({ context, important }, x.toString());
} else if (typeof x === 'object') {
context = context ?? {};
context.error = context.error ?? x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
if (important) this.logger.fatal({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
else this.logger.error({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
@ -85,16 +91,18 @@ export default class Logger {
@bindThis
public warn(x: string | Error, context?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う
// eslint-disable-next-line no-param-reassign
if (context === null) context = undefined;
if (context?.error) context.error = pino.stdSerializers.errWithCause(context.error);
if (x instanceof Error) {
context = context ?? {};
context.error = x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
this.logger.warn({ context, important }, x.toString());
} else if (typeof x === 'object') {
context = context ?? {};
context.error = context.error ?? x;
// eslint-disable-next-line no-param-reassign
if (context?.error === undefined) context = { ...context, error: pino.stdSerializers.errWithCause(x) };
this.logger.warn({ context, important }, `${(x as any).message ?? (x as any).name ?? x}`);
} else {

View file

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

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Column, Index } from 'typeorm';
@Entity('sso_service_provider')
export class MiSingleSignOnServiceProvider {
@PrimaryColumn('varchar', {
length: 36,
})
public id: string;
@Index()
@Column('timestamp with time zone', {
default: () => 'CURRENT_TIMESTAMP',
})
public createdAt: Date;
@Column('varchar', {
length: 256, nullable: true,
})
public name: string | null;
@Column('enum', {
enum: ['saml', 'jwt'],
nullable: false,
})
public type: 'saml' | 'jwt';
@Column('varchar', {
length: 512,
})
public issuer: string;
@Column('varchar', {
array: true, length: 512, default: '{}',
})
public audience: string[];
@Column('varchar', {
length: 512,
})
public acsUrl: string;
@Column('varchar', {
length: 4096,
})
public publicKey: string;
@Column('varchar', {
length: 4096, nullable: true,
})
public privateKey: string | null;
@Column('varchar', {
length: 100,
})
public signatureAlgorithm: string;
@Column('varchar', {
length: 100, nullable: true,
})
public cipherAlgorithm: string | null;
@Column('boolean', {
default: false,
})
public wantAuthnRequestsSigned: boolean;
@Column('boolean', {
default: true,
})
public wantAssertionsSigned: boolean;
}

View file

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

View file

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

View file

@ -28,6 +28,8 @@ import { FeedService } from './web/FeedService.js';
import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { JWTIdentifyProviderService } from './sso/JWTIdentifyProviderService.js';
import { SAMLIdentifyProviderService } from './sso/SAMLIdentifyProviderService.js';
import { MainChannelService } from './api/stream/channels/main.js';
import { AdminChannelService } from './api/stream/channels/admin.js';
import { AntennaChannelService } from './api/stream/channels/antenna.js';
@ -89,6 +91,8 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
UserListChannelService,
OpenApiServerService,
OAuth2ProviderService,
JWTIdentifyProviderService,
SAMLIdentifyProviderService,
],
exports: [
ServerService,

View file

@ -33,6 +33,8 @@ import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { JWTIdentifyProviderService } from './sso/JWTIdentifyProviderService.js';
import { SAMLIdentifyProviderService } from './sso/SAMLIdentifyProviderService.js';
const _dirname = fileURLToPath(new URL('.', import.meta.url));
@ -67,6 +69,8 @@ export class ServerService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private loggerService: LoggerService,
private oauth2ProviderService: OAuth2ProviderService,
private jwtIdentifyProviderService: JWTIdentifyProviderService,
private samlIdentifyProviderService: SAMLIdentifyProviderService,
) {
this.logger = this.loggerService.getLogger('server', 'gray', false);
}
@ -117,6 +121,9 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
fastify.register(this.oauth2ProviderService.createApiServer, { prefix: '/oauth/api' });
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
fastify.register(this.samlIdentifyProviderService.createServer, { prefix: '/sso/saml' });
fastify.register(this.jwtIdentifyProviderService.createServer, { prefix: '/sso/jwt' });
fastify.register(this.jwtIdentifyProviderService.createApiServer, { prefix: '/sso/jwt/api' });
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path;

View file

@ -90,6 +90,10 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___admin_sso_create from './endpoints/admin/sso/create.js';
import * as ep___admin_sso_delete from './endpoints/admin/sso/delete.js';
import * as ep___admin_sso_list from './endpoints/admin/sso/list.js';
import * as ep___admin_sso_update from './endpoints/admin/sso/update.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -472,6 +476,10 @@ const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useCla
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
const $admin_sso_create: Provider = { provide: 'ep:admin/sso/create', useClass: ep___admin_sso_create.default };
const $admin_sso_delete: Provider = { provide: 'ep:admin/sso/delete', useClass: ep___admin_sso_delete.default };
const $admin_sso_list: Provider = { provide: 'ep:admin/sso/list', useClass: ep___admin_sso_list.default };
const $admin_sso_update: Provider = { provide: 'ep:admin/sso/update', useClass: ep___admin_sso_update.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
@ -858,6 +866,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$admin_sso_create,
$admin_sso_delete,
$admin_sso_list,
$admin_sso_update,
$announcements,
$antennas_create,
$antennas_delete,
@ -1238,6 +1250,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$admin_sso_create,
$admin_sso_delete,
$admin_sso_list,
$admin_sso_update,
$announcements,
$antennas_create,
$antennas_delete,

View file

@ -90,6 +90,10 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___admin_sso_create from './endpoints/admin/sso/create.js';
import * as ep___admin_sso_delete from './endpoints/admin/sso/delete.js';
import * as ep___admin_sso_list from './endpoints/admin/sso/list.js';
import * as ep___admin_sso_update from './endpoints/admin/sso/update.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -470,6 +474,10 @@ const eps = [
['admin/roles/unassign', ep___admin_roles_unassign],
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['admin/roles/users', ep___admin_roles_users],
['admin/sso/create', ep___admin_sso_create],
['admin/sso/delete', ep___admin_sso_delete],
['admin/sso/list', ep___admin_sso_list],
['admin/sso/update', ep___admin_sso_update],
['announcements', ep___announcements],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],

View file

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { IndieAuthClientsRepository } from '@/models/_.js';
@ -70,7 +65,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const indieAuthClient = await this.indieAuthClientsRepository.insert({
id: ps.id,
createdAt: new Date(),
name: ps.name,
name: ps.name ? ps.name : null,
redirectUris: ps.redirectUris,
}).then(r => this.indieAuthClientsRepository.findOneByOrFail({ id: r.identifiers[0].id }));

View file

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

View file

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

View file

@ -1,8 +1,3 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { IndieAuthClientsRepository } from '@/models/_.js';
@ -53,7 +48,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (client == null) throw new ApiError(meta.errors.noSuchIndieAuthClient);
await this.indieAuthClientsRepository.update(client.id, {
name: ps.name,
name: ps.name !== '' ? ps.name : null,
redirectUris: ps.redirectUris,
});

View file

@ -0,0 +1,159 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import * as jose from 'jose';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { DI } from '@/di-symbols.js';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:sso',
errors: {
invalidParamSamlUseCertificate: {
message: 'SAML service provider must use certificate.',
code: 'INVALID_PARAM',
id: 'bb97e559-f23c-4d6a-9e4e-eb5db1f467f9',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: true,
},
type: {
type: 'string',
optional: false, nullable: false,
enum: ['saml', 'jwt'],
},
issuer: {
type: 'string',
optional: false, nullable: false,
},
audience: {
type: 'array',
optional: false, nullable: false,
items: { type: 'string', nullable: false },
},
acsUrl: {
type: 'string',
optional: false, nullable: false,
},
publicKey: {
type: 'string',
optional: false, nullable: false,
},
signatureAlgorithm: {
type: 'string',
optional: false, nullable: false,
},
cipherAlgorithm: {
type: 'string',
optional: true, nullable: true,
},
wantAuthnRequestsSigned: {
type: 'boolean',
optional: false, nullable: false,
},
wantAssertionsSigned: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', nullable: true },
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
issuer: { type: 'string', nullable: false },
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
acsUrl: { type: 'string', nullable: false },
signatureAlgorithm: { type: 'string', nullable: false },
cipherAlgorithm: { type: 'string', nullable: true },
wantAuthnRequestsSigned: { type: 'boolean', nullable: false, default: false },
wantAssertionsSigned: { type: 'boolean', nullable: false, default: true },
useCertificate: { type: 'boolean', nullable: false, default: true },
secret: { type: 'string', nullable: true },
},
required: ['type', 'issuer', 'acsUrl', 'signatureAlgorithm', 'useCertificate'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.type === 'saml' && ps.useCertificate === false) {
throw new ApiError(meta.errors.invalidParamSamlUseCertificate);
}
const { publicKey, privateKey } = ps.useCertificate
? await jose.generateKeyPair(ps.signatureAlgorithm).then(async keypair => ({
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
}))
: { publicKey: ps.secret ?? randomUUID(), privateKey: null };
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.insert({
id: randomUUID(),
createdAt: new Date(),
name: ps.name ? ps.name : null,
type: ps.type,
issuer: ps.issuer,
audience: ps.audience,
acsUrl: ps.acsUrl,
publicKey: publicKey,
privateKey: privateKey,
signatureAlgorithm: ps.signatureAlgorithm,
cipherAlgorithm: ps.cipherAlgorithm ? ps.cipherAlgorithm : null,
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
wantAssertionsSigned: ps.wantAssertionsSigned,
}).then(r => this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: r.identifiers[0].id }));
this.moderationLogService.log(me, 'createSSOServiceProvider', {
serviceId: ssoServiceProvider.id,
service: ssoServiceProvider,
});
return {
id: ssoServiceProvider.id,
createdAt: ssoServiceProvider.createdAt.toISOString(),
name: ssoServiceProvider.name,
type: ssoServiceProvider.type,
issuer: ssoServiceProvider.issuer,
audience: ssoServiceProvider.audience,
acsUrl: ssoServiceProvider.acsUrl,
publicKey: ssoServiceProvider.publicKey,
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
cipherAlgorithm: ssoServiceProvider.cipherAlgorithm,
wantAuthnRequestsSigned: ssoServiceProvider.wantAuthnRequestsSigned,
wantAssertionsSigned: ssoServiceProvider.wantAssertionsSigned,
};
});
}
}

View file

@ -0,0 +1,53 @@
import { Inject, Injectable } from '@nestjs/common';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:sso',
errors: {
noSuchSingleSignOnServiceProvider: {
message: 'No such SSO Service Provider',
code: 'NO_SUCH_SSO_SP',
id: 'ece541d3-6c41-4fc3-a514-fa762b96704a',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
await this.singleSignOnServiceProviderRepository.delete(service.id);
this.moderationLogService.log(me, 'deleteSSOServiceProvider', {
serviceId: service.id,
service: service,
});
});
}
}

View file

@ -0,0 +1,111 @@
import { Inject, Injectable } from '@nestjs/common';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'read:admin:sso',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: true,
},
type: {
type: 'string',
optional: false, nullable: false,
enum: ['saml', 'jwt'],
},
issuer: {
type: 'string',
optional: false, nullable: false,
},
audience: {
type: 'array',
optional: false, nullable: false,
items: { type: 'string', nullable: false },
},
acsUrl: {
type: 'string',
optional: false, nullable: false,
},
publicKey: {
type: 'string',
optional: false, nullable: false,
},
signatureAlgorithm: {
type: 'string',
optional: false, nullable: false,
},
cipherAlgorithm: {
type: 'string',
optional: true, nullable: true,
},
wantAuthnRequestsSigned: {
type: 'boolean',
optional: false, nullable: false,
},
wantAssertionsSigned: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.singleSignOnServiceProviderRepository.createQueryBuilder('service');
const services = await query.offset(ps.offset).limit(ps.limit).getMany();
return services.map(service => ({
id: service.id,
createdAt: service.createdAt.toISOString(),
name: service.name,
type: service.type,
issuer: service.issuer,
audience: service.audience,
acsUrl: service.acsUrl,
publicKey: service.publicKey,
signatureAlgorithm: service.signatureAlgorithm,
cipherAlgorithm: service.cipherAlgorithm,
wantAuthnRequestsSigned: service.wantAuthnRequestsSigned,
wantAssertionsSigned: service.wantAssertionsSigned,
}));
});
}
}

View file

@ -0,0 +1,86 @@
import * as jose from 'jose';
import { Inject, Injectable } from '@nestjs/common';
import type { SingleSignOnServiceProviderRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:sso',
errors: {
noSuchSingleSignOnServiceProvider: {
message: 'No such SSO Service Provider',
code: 'NO_SUCH_SSO_SP',
id: '2f481db0-23f5-4380-8cb8-704169ffb25b',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
issuer: { type: 'string' },
audience: { type: 'array', items: { type: 'string', nullable: false } },
acsUrl: { type: 'string' },
signatureAlgorithm: { type: 'string' },
cipherAlgorithm: { type: 'string' },
wantAuthnRequestsSigned: { type: 'boolean' },
wantAssertionsSigned: { type: 'boolean' },
regenerateCertificate: { type: 'boolean' },
secret: { type: 'string' },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const service = await this.singleSignOnServiceProviderRepository.findOneBy({ id: ps.id });
if (service == null) throw new ApiError(meta.errors.noSuchSingleSignOnServiceProvider);
const alg = ps.signatureAlgorithm ? ps.signatureAlgorithm : service.signatureAlgorithm;
const { publicKey, privateKey } = ps.regenerateCertificate
? await jose.generateKeyPair(alg).then(async keypair => ({
publicKey: JSON.stringify(await jose.exportJWK(keypair.publicKey)),
privateKey: JSON.stringify(await jose.exportJWK(keypair.privateKey)),
}))
: { publicKey: ps.secret ?? undefined, privateKey: undefined };
await this.singleSignOnServiceProviderRepository.update(service.id, {
name: ps.name !== '' ? ps.name : null,
issuer: ps.issuer,
audience: ps.audience,
acsUrl: ps.acsUrl,
publicKey: publicKey,
privateKey: privateKey,
signatureAlgorithm: ps.signatureAlgorithm,
cipherAlgorithm: ps.cipherAlgorithm !== '' ? ps.cipherAlgorithm : null,
wantAuthnRequestsSigned: ps.wantAuthnRequestsSigned,
wantAssertionsSigned: ps.wantAssertionsSigned,
});
const updatedService = await this.singleSignOnServiceProviderRepository.findOneByOrFail({ id: service.id });
this.moderationLogService.log(me, 'updateSSOServiceProvider', {
serviceId: service.id,
before: service,
after: updatedService,
});
});
}
}

View file

@ -36,7 +36,7 @@ import type {
AccessTokensRepository,
IndieAuthClientsRepository,
UserProfilesRepository,
UsersRepository
UsersRepository,
} from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
@ -474,7 +474,7 @@ export class OAuth2ProviderService {
fastify.use('/decision', this.#server.decision((req, done) => {
const { body } = req as OAuth2DecisionRequest;
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
req.user = body.login_token;
if (!body.cancel) req.user = body.login_token;
done(null, undefined);
}));
fastify.use('/decision', this.#server.errorHandler());
@ -508,7 +508,7 @@ export class OAuth2ProviderService {
return;
}
const accessToken = await this.accessTokensRepository.findOneBy({ token });
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
if (!accessToken) {
reply.code(401);
return;
@ -525,7 +525,8 @@ export class OAuth2ProviderService {
picture: accessToken.user?.avatarUrl,
email: user?.email,
email_verified: user?.emailVerified,
updated_at: (accessToken.lastUsedAt?.getTime() ?? 0) / 1000,
mfa_enabled: user?.twoFactorEnabled,
updated_at: (accessToken.user?.updatedAt?.getTime() ?? accessToken.user?.createdAt.getTime() ?? 0) / 1000,
};
});
}
@ -543,7 +544,7 @@ export class OAuth2ProviderService {
return;
}
const accessToken = await this.accessTokensRepository.findOneBy({ token });
const accessToken = await this.accessTokensRepository.findOne({ where: { token }, relations: ['user'] });
reply.code(200);
if (!accessToken) return { active: false };

View file

@ -0,0 +1,375 @@
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import pug from 'pug';
import fastifyView from '@fastify/view';
import fastifyCors from '@fastify/cors';
import fastifyFormbody from '@fastify/formbody';
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
import * as jose from 'jose';
import { JWTPayload } from 'jose';
import Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Config } from '@/config.js';
import type {
SingleSignOnServiceProviderRepository,
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import type { MiLocalUser } from '@/models/User.js';
import { CacheService } from '@/core/CacheService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { RoleService } from '@/core/RoleService.js';
import type { FastifyInstance } from 'fastify';
@Injectable()
export class JWTIdentifyProviderService {
#logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private cacheService: CacheService,
private loggerService: LoggerService,
) {
this.#logger = this.loggerService.getLogger('sso:jwt');
}
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
fastify.register(fastifyFormbody);
fastify.register(fastifyCors);
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
fastify.all<{
Params: { serviceId: string };
Querystring?: { return_to?: string };
Body?: { return_to?: string };
}>('/:serviceId', async (request, reply) => {
const serviceId = request.params.serviceId;
const returnTo = request.query?.return_to ?? request.body?.return_to;
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: 'c6aafae6-e8b9-420c-a87a-6ac08402165b',
kind: 'client',
},
});
return;
}
const transactionId = randomUUID();
await this.redisClient.set(
`sso:jwt:transaction:${transactionId}`,
JSON.stringify({
serviceId: serviceId,
returnTo: returnTo,
}),
'EX',
60 * 5,
);
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
reply.header('Cache-Control', 'no-store');
return await reply.view('sso', {
transactionId: transactionId,
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
kind: 'jwt',
});
});
fastify.post<{
Body: { transaction_id: string; login_token: string; cancel?: string };
}>('/authorize', async (request, reply) => {
const transactionId = request.body.transaction_id;
const token = request.body.login_token;
const cancel = !!request.body.cancel;
if (cancel) {
reply.redirect('/');
return;
}
const transaction = await this.redisClient.get(`sso:jwt:transaction:${transactionId}`);
if (!transaction) {
reply.status(403).send({
error: {
message: 'Invalid transaction id',
code: 'INVALID_TRANSACTION_ID',
id: '91fa6511-0b33-47d6-bd01-b420d80fcd6a',
kind: 'client',
},
});
return;
}
const { serviceId, returnTo } = JSON.parse(transaction);
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: 'c038610c-4c11-40ce-9371-131d5720f511',
kind: 'client',
},
});
return;
}
if (!token) {
reply.status(401).send({
error: {
message: 'No login token',
code: 'NO_LOGIN_TOKEN',
id: '399e756c-35cd-459c-a7ba-8cc12eb39eef',
kind: 'client',
},
});
return;
}
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
token,
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
);
if (!user) {
reply.status(403).send({
error: {
message: 'Invalid login token',
code: 'INVALID_LOGIN_TOKEN',
id: '3b92ee31-9215-447a-805f-df8f15ffb8b2',
kind: 'client',
},
});
return;
}
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const isAdministrator = await this.roleService.isAdministrator(user);
const isModerator = await this.roleService.isModerator(user);
const roles = await this.roleService.getUserRoles(user.id);
const payload: JWTPayload = {
name: user.name,
preferred_username: user.username,
profile: `${this.config.url}/@${user.username}`,
picture: user.avatarUrl,
email: profile.email,
email_verified: profile.emailVerified,
mfa_enabled: profile.twoFactorEnabled,
updated_at: (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
admin: isAdministrator,
moderator: isModerator,
roles: roles.filter(r => r.isPublic).map(r => r.id),
};
try {
if (ssoServiceProvider.cipherAlgorithm) {
const key = ssoServiceProvider.publicKey.startsWith('{')
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
: jose.base64url.decode(ssoServiceProvider.publicKey);
const jwt = await new jose.EncryptJWT(payload)
.setProtectedHeader({
alg: ssoServiceProvider.signatureAlgorithm,
enc: ssoServiceProvider.cipherAlgorithm,
})
.setIssuer(ssoServiceProvider.issuer)
.setAudience(ssoServiceProvider.audience)
.setIssuedAt()
.setExpirationTime('10m')
.setJti(randomUUID())
.setSubject(user.id)
.encrypt(key);
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
userId: user.id,
ssoServiceProvider: ssoServiceProvider.id,
acsUrl: ssoServiceProvider.acsUrl,
returnTo,
});
if (returnTo) {
reply.redirect(
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
);
return;
} else {
reply.redirect(
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
);
return;
}
} else {
const key = ssoServiceProvider.privateKey
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
: jose.base64url.decode(ssoServiceProvider.publicKey);
const jwt = await new jose.SignJWT(payload)
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
.setIssuer(ssoServiceProvider.issuer)
.setAudience(ssoServiceProvider.audience)
.setIssuedAt()
.setExpirationTime('10m')
.setJti(randomUUID())
.setSubject(user.id)
.sign(key);
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
userId: user.id,
ssoServiceProvider: ssoServiceProvider.id,
acsUrl: ssoServiceProvider.acsUrl,
returnTo,
});
if (returnTo) {
reply.redirect(
`${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
);
return;
} else {
reply.redirect(
`${ssoServiceProvider.acsUrl}?jwt=${jwt}`,
);
return;
}
}
} catch (err) {
this.#logger.error('Failed to create JWT', { error: err });
const traceableError = err as Error & { code?: string };
if (traceableError.code) {
reply.status(500).send({
error: {
message: traceableError.message,
code: traceableError.code,
id: 'a436fa15-20ca-4269-ac4d-ee162fe1f3b0',
kind: 'server',
},
});
return;
}
reply.status(500).send({
error: {
message: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR',
id: 'fe1c597c-a515-46a1-860b-bd316b11aff9',
kind: 'server',
},
});
return;
} finally {
await this.redisClient.del(`sso:jwt:transaction:${transactionId}`);
}
});
}
@bindThis
public async createApiServer(fastify: FastifyInstance): Promise<void> {
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
fastify.register(fastifyFormbody);
fastify.register(fastifyCors);
fastify.post<{
Params: { serviceId: string };
Body: { jwt: string };
}>('/verify/:serviceId', async (request, reply) => {
const serviceId = request.params.serviceId;
const jwt = request.body.jwt;
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'jwt' });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: '077e0930-88c1-4f25-bd4e-4da8e34f735b',
kind: 'client',
},
});
return;
}
try {
if (ssoServiceProvider.cipherAlgorithm) {
const key = ssoServiceProvider.privateKey
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
: jose.base64url.decode(ssoServiceProvider.publicKey);
const { payload } = await jose.jwtDecrypt(jwt, key, {
issuer: ssoServiceProvider.issuer,
audience: ssoServiceProvider.audience,
});
reply.status(200).send({ payload });
return;
} else {
const key = ssoServiceProvider.publicKey.startsWith('{')
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
: jose.base64url.decode(ssoServiceProvider.publicKey);
const { payload } = await jose.jwtVerify(jwt, key, {
issuer: ssoServiceProvider.issuer,
audience: ssoServiceProvider.audience,
});
reply.status(200).send({ payload });
return;
}
} catch (err) {
this.#logger.error('Failed to verify JWT', { error: err });
const traceableError = err as Error & { code?: string };
if (traceableError.code) {
reply.status(400).send({
error: {
message: traceableError.message,
code: traceableError.code,
id: '843421cf-3ab3-4b1f-ade4-5d5ce1efb6be',
kind: 'client',
},
});
return;
}
reply.status(400).send({
error: {
message: 'Invalid JWT',
code: 'INVALID_JWT',
id: '39075dbb-03eb-485f-8ee1-f16b625bcc4d',
kind: 'client',
},
});
return;
}
});
}
}

View file

@ -0,0 +1,654 @@
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import * as jose from 'jose';
import * as Redis from 'ioredis';
import * as saml from 'samlify';
import * as validator from '@authenio/samlify-node-xmllint';
import fastifyView from '@fastify/view';
import fastifyCors from '@fastify/cors';
import fastifyFormbody from '@fastify/formbody';
import fastifyHttpErrorsEnhanced from 'fastify-http-errors-enhanced';
import pug from 'pug';
import xmlbuilder from 'xmlbuilder';
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import Logger from '@/logger.js';
import type {
MiSingleSignOnServiceProvider,
SingleSignOnServiceProviderRepository,
UserProfilesRepository,
UsersRepository,
} from '@/models/_.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
import { LoggerService } from '@/core/LoggerService.js';
import { RoleService } from '@/core/RoleService.js';
import type { MiLocalUser } from '@/models/User.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { FastifyInstance } from 'fastify';
@Injectable()
export class SAMLIdentifyProviderService {
#logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.singleSignOnServiceProviderRepository)
private singleSignOnServiceProviderRepository: SingleSignOnServiceProviderRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private roleService: RoleService,
private cacheService: CacheService,
private loggerService: LoggerService,
) {
this.#logger = this.loggerService.getLogger('sso:saml');
saml.setSchemaValidator(validator);
}
public async createIdPMetadataXml(
provider: MiSingleSignOnServiceProvider,
): Promise<string> {
const today = new Date();
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
const nodes = {
'md:EntityDescriptor': {
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'@entityID': provider.issuer,
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
'md:IDPSSODescriptor': {
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
'md:KeyDescriptor': {
'@use': 'signing',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': publicKey,
},
},
},
},
'md:NameIDFormat': {
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
},
'md:SingleSignOnService': [
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
},
{
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
},
],
},
},
};
return xmlbuilder
.create(nodes, { encoding: 'UTF-8', standalone: false })
.end({ pretty: true });
}
public async createSPMetadataXml(
provider: MiSingleSignOnServiceProvider,
): Promise<string> {
const today = new Date();
const publicKey = await jose.importJWK(JSON.parse(provider.publicKey)).then((r) => jose.exportSPKI(r as jose.KeyLike));
const keyDescriptor: unknown[] = [
{
'@use': 'signing',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': publicKey,
},
},
},
},
];
if (provider.cipherAlgorithm) {
keyDescriptor.push({
'@use': 'encryption',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': publicKey,
},
},
},
'md:EncryptionMethod': {
'@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc',
},
});
}
const nodes = {
'md:EntityDescriptor': {
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'@entityID': provider.issuer,
'@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(),
'md:SPSSODescriptor': {
'@AuthnRequestsSigned': provider.wantAuthnRequestsSigned,
'@WantAssertionsSigned': provider.wantAssertionsSigned,
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
'md:KeyDescriptor': keyDescriptor,
'md:NameIDFormat': {
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
},
'md:AssertionConsumerService': {
'@index': 1,
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
'@Location': provider.acsUrl,
},
},
},
};
return xmlbuilder
.create(nodes, { encoding: 'UTF-8', standalone: false })
.end({ pretty: true });
}
/**
* @desc Alternative to lodash.get
* @reference https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get
* @param obj
* @param path
* @param defaultValue
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private get(obj: any, path: string, defaultValue: unknown) {
return path
.split('.')
.reduce((a, c) => (a?.[c] ? a[c] : defaultValue || null), obj);
}
@bindThis
public async createServer(fastify: FastifyInstance): Promise<void> {
fastify.register(fastifyHttpErrorsEnhanced, { preHandler: (error: Error): Error => { this.#logger.error(error); return error; } });
fastify.register(fastifyFormbody);
fastify.register(fastifyCors);
fastify.register(fastifyView, {
root: fileURLToPath(new URL('../web/views', import.meta.url)),
engine: { pug },
defaultContext: {
version: this.config.version,
config: this.config,
},
});
fastify.all<{
Params: { serviceId: string };
Querystring?: { SAMLRequest?: string; RelayState?: string };
Body?: { SAMLRequest?: string; RelayState?: string };
}>('/:serviceId', async (request, reply) => {
const serviceId = request.params.serviceId;
const binding = request.query?.SAMLRequest ? 'redirect' : 'post';
const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest;
const relayState = request.query?.RelayState ?? request.body?.RelayState;
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml', privateKey: Not(IsNull()) });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: 'e2893d7e-df6f-44cf-8717-42234b8ac0ce',
kind: 'client',
},
});
return;
}
if (!samlRequest) {
reply.status(400).send({
error: {
message: 'No SAMLRequest',
code: 'NO_SAML_REQUEST',
id: 'c58bc7e3-f92e-4879-a6a9-7258a13bc491',
kind: 'client',
},
});
return;
}
const idp = saml.IdentityProvider({
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
privateKey: await jose
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
});
const sp = saml.ServiceProvider({
metadata: await this.createSPMetadataXml(ssoServiceProvider),
});
const parsed = await idp.parseLoginRequest(sp, binding, { query: request.query, body: request.body });
this.#logger.info('Parsed SAML request', { saml: parsed });
const transactionId = randomUUID();
await this.redisClient.set(
`sso:saml:transaction:${transactionId}`,
JSON.stringify({
serviceId: serviceId,
binding: binding,
flowResult: parsed,
relayState: relayState,
}),
'EX',
60 * 5,
);
this.#logger.info(`Rendering authorization page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
reply.header('Cache-Control', 'no-store');
return await reply.view('sso', {
transactionId: transactionId,
serviceName: ssoServiceProvider.name ?? ssoServiceProvider.issuer,
kind: 'saml',
});
});
fastify.get<{ Params: { serviceId: string } }>(
'/:serviceId/metadata',
async (request, reply) => {
const serviceId = request.params.serviceId;
const ssoServiceProvider = await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: '8a6d72e1-3530-4ec0-9d4d-b105fdbb8a2d',
kind: 'client',
},
});
return;
}
reply.header('Content-Type', 'application/xml');
reply.send(await this.createIdPMetadataXml(ssoServiceProvider));
},
);
fastify.post<{
Body: { transaction_id: string; login_token: string; cancel?: string };
}>('/authorize', async (request, reply) => {
const transactionId = request.body.transaction_id;
const token = request.body.login_token;
const cancel = !!request.body.cancel;
if (cancel) {
reply.redirect('/');
return;
}
const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`);
if (!transaction) {
reply.status(403).send({
error: {
message: 'Invalid transaction id',
code: 'INVALID_TRANSACTION_ID',
id: 'cca6ea16-5f04-4d9e-9ef5-8a99bdef3a92',
kind: 'client',
},
});
return;
}
const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction);
const ssoServiceProvider =
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
if (!ssoServiceProvider) {
reply.status(403).send({
error: {
message: 'Invalid SSO Service Provider id',
code: 'INVALID_SSO_SP_ID',
id: 'f644adfe-019a-478c-b5a9-897a2556f2b2',
kind: 'client',
},
});
return;
}
if (!token) {
reply.status(401).send({
error: {
message: 'No login token',
code: 'NO_LOGIN_TOKEN',
id: 'cd96295e-0370-433d-a3de-421de4536b7f',
kind: 'client',
},
});
return;
}
const user = await this.cacheService.localUserByNativeTokenCache.fetch(
token,
() => this.usersRepository.findOneBy({ token }) as Promise<MiLocalUser | null>,
);
if (!user) {
reply.status(403).send({
error: {
message: 'Invalid login token',
code: 'INVALID_LOGIN_TOKEN',
id: 'a002a4ed-0024-460f-8015-cc5e7c6cd0a7',
kind: 'client',
},
});
return;
}
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const isAdministrator = await this.roleService.isAdministrator(user);
const isModerator = await this.roleService.isModerator(user);
const roles = await this.roleService.getUserRoles(user.id);
try {
const idp = saml.IdentityProvider({
metadata: await this.createIdPMetadataXml(ssoServiceProvider),
privateKey: await jose
.importJWK(JSON.parse(ssoServiceProvider.privateKey ?? '{}'))
.then((r) => jose.exportPKCS8(r as jose.KeyLike)),
loginResponseTemplate: { context: 'ignored' },
});
const sp = saml.ServiceProvider({
metadata: await this.createSPMetadataXml(ssoServiceProvider),
});
const samlResponse = await idp.createLoginResponse(
sp,
flowResult,
binding,
{},
() => {
const id = idp.entitySetting.generateID?.() ?? randomUUID();
const assertionId = idp.entitySetting.generateID?.() ?? randomUUID();
const nowTime = new Date();
const fiveMinutesLaterTime = new Date(nowTime.getTime());
fiveMinutesLaterTime.setMinutes(fiveMinutesLaterTime.getMinutes() + 5);
const now = nowTime.toISOString();
const fiveMinutesLater = fiveMinutesLaterTime.toISOString();
const nodes = {
'samlp:Response': {
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
'@ID': id,
'@Version': '2.0',
'@IssueInstant': now,
'@Destination': ssoServiceProvider.acsUrl,
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
'saml:Issuer': {
'#text': ssoServiceProvider.issuer,
},
'samlp:Status': {
'samlp:StatusCode': {
'@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success',
},
},
'saml:Assertion': {
'@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'@xmlns:xs': 'http://www.w3.org/2001/XMLSchema',
'@ID': assertionId,
'@Version': '2.0',
'@IssueInstant': now,
'saml:Issuer': {
'#text': ssoServiceProvider.issuer,
},
'saml:Subject': {
'saml:NameID': {
'@Format':
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
'#text': user.id,
},
'saml:SubjectConfirmation': {
'@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer',
'saml:SubjectConfirmationData': {
'@InResponseTo': this.get(flowResult, 'extract.request.id', ''),
'@NotOnOrAfter': fiveMinutesLater,
'@Recipient': ssoServiceProvider.acsUrl,
},
},
},
'saml:Conditions': {
'@NotBefore': now,
'@NotOnOrAfter': fiveMinutesLater,
'saml:AudienceRestriction': {
'saml:Audience': [
{ '#text': ssoServiceProvider.issuer },
...ssoServiceProvider.audience.map((audience) => ({
'#text': audience,
})),
],
},
},
'saml:AuthnStatement': {
'@AuthnInstant': now,
'@SessionIndex': assertionId,
'@SessionNotOnOrAfter': fiveMinutesLater,
'saml:AuthnContext': {
'saml:AuthnContextClassRef': {
'#text':
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
},
},
},
'saml:AttributeStatement': {
'saml:Attribute': [
{
'@Name': 'identityprovider',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': this.config.url,
},
},
{
'@Name': 'uid',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': user.id,
},
},
{
'@Name': 'displayname',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': user.name,
},
},
{
'@Name': 'name',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': user.username,
},
},
{
'@Name': 'preferred_username',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': user.username,
},
},
{
'@Name': 'profile',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': `${this.config.url}/@${user.username}`,
},
},
{
'@Name': 'picture',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': user.avatarUrl,
},
},
{
'@Name': 'mail',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': profile.email,
},
},
{
'@Name': 'email',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:string',
'#text': profile.email,
},
},
{
'@Name': 'email_verified',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:boolean',
'#text': profile.emailVerified,
},
},
{
'@Name': 'mfa_enabled',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:boolean',
'#text': profile.twoFactorEnabled,
},
},
{
'@Name': 'updated_at',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:integer',
'#text': (user.updatedAt?.getTime() ?? user.createdAt.getTime()) / 1000,
},
},
{
'@Name': 'admin',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:boolean',
'#text': isAdministrator,
},
},
{
'@Name': 'moderator',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': {
'@xsi:type': 'xs:boolean',
'#text': isModerator,
},
},
{
'@Name': 'roles',
'@NameFormat':
'urn:oasis:names:tc:SAML:2.0:attrname-format:basic',
'saml:AttributeValue': [
...roles
.filter((r) => r.isPublic)
.map((r) => ({
'@xsi:type': 'xs:string',
'#text': r.id,
})),
],
},
],
},
},
},
};
return {
id,
context: xmlbuilder
.create(nodes, { encoding: 'UTF-8', standalone: false })
.end({ pretty: false }),
};
},
undefined,
relayState,
);
this.#logger.info(`Rendering SAML response page for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`, {
userId: user.id,
ssoServiceProvider: ssoServiceProvider.id,
acsUrl: ssoServiceProvider.acsUrl,
relayState: relayState,
});
reply.header('Cache-Control', 'no-store');
return await reply.view('sso-saml-post', {
acsUrl: ssoServiceProvider.acsUrl,
samlResponse: samlResponse,
relyState: relayState ?? null,
});
} catch (err) {
this.#logger.error('Failed to create SAML response', { error: err });
const traceableError = err as Error & { code?: string };
if (traceableError.code) {
reply.status(500).send({
error: {
message: traceableError.message,
code: traceableError.code,
id: 'a743ff78-8636-4b69-a54f-e3b395564f79',
kind: 'server',
},
});
return;
}
reply.status(500).send({
error: {
message: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR',
id: 'b83b7afd-adfc-4baf-8659-34623d639170',
kind: 'server',
},
});
return;
} finally {
await this.redisClient.del(`sso:saml:transaction:${transactionId}`);
}
});
}
}

View file

@ -0,0 +1,21 @@
html
body
noscript: p
| JavaScriptを有効にしてください
br
| Please turn on your JavaScript
p
| Redirecting...
form(id='sso', method='post', action=action autocomplete='off')
input(type='hidden', name='SAMLResponse', value=samlResponse)
if relayState !== null
input(type='hidden', name='RelayState', value=relayState)
button(type='submit')
| click here if you are not redirected.
script.
document.forms[0].submit();

View file

@ -0,0 +1,6 @@
extends ./base
block meta
meta(name='misskey:sso:transaction-id' content=transactionId)
meta(name='misskey:sso:service-name' content=serviceName)
meta(name='misskey:sso:kind' content=kind)

View file

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