enhance(SSO): SAML認証でHTTP-POSTバインディングに対応 (MisskeyIO#531)

This commit is contained in:
まっちゃとーにゅ 2024-03-17 20:58:53 +09:00
parent 27c897d19f
commit aebe9ae148
No known key found for this signature in database
GPG Key ID: 6AFBBF529601C1DB
16 changed files with 185 additions and 107 deletions

View File

@ -1,15 +0,0 @@
export class SingleSignOn1710416761960 {
name = 'SingleSignOn1710416761960'
async up(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_type_enum" AS ENUM('saml', 'jwt')`);
await queryRunner.query(`CREATE TABLE "sso_service_provider" ("id" character varying(36) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256), "type" "public"."sso_service_provider_type_enum" NOT NULL, "issuer" character varying(512) NOT NULL, "audience" character varying(512) array NOT NULL DEFAULT '{}', "acsUrl" character varying(512) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096), "signatureAlgorithm" character varying(100) NOT NULL, "cipherAlgorithm" character varying(100), "wantAuthnRequestsSigned" boolean NOT NULL DEFAULT false, "wantAssertionsSigned" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0e5fff64534026e48e1c248991a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_86eee7fa4ae68e4a558dc50961" ON "sso_service_provider" ("createdAt") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_86eee7fa4ae68e4a558dc50961"`);
await queryRunner.query(`DROP TABLE "sso_service_provider"`);
await queryRunner.query(`DROP TYPE "public"."sso_service_provider_type_enum"`);
}
}

View File

@ -0,0 +1,21 @@
export class SingleSignOn1710667213868 {
name = 'SingleSignOn1710667213868'
async up(queryRunner) {
await queryRunner.query(`DROP TABLE IF EXISTS "sso_service_provider"`);
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_86eee7fa4ae68e4a558dc50961"`);
await queryRunner.query(`DROP TYPE IF EXISTS "public"."sso_service_provider_binding_enum"`);
await queryRunner.query(`DROP TYPE IF EXISTS "public"."sso_service_provider_type_enum"`);
await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_type_enum" AS ENUM('saml', 'jwt')`);
await queryRunner.query(`CREATE TYPE "public"."sso_service_provider_binding_enum" AS ENUM('post', 'redirect')`);
await queryRunner.query(`CREATE TABLE "sso_service_provider" ("id" character varying(36) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256), "type" "public"."sso_service_provider_type_enum" NOT NULL, "issuer" character varying(512) NOT NULL, "audience" character varying(512) array NOT NULL DEFAULT '{}', "binding" "public"."sso_service_provider_binding_enum" NOT NULL, "acsUrl" character varying(512) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096), "signatureAlgorithm" character varying(100) NOT NULL, "cipherAlgorithm" character varying(100), "wantAuthnRequestsSigned" boolean NOT NULL DEFAULT false, "wantAssertionsSigned" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_0e5fff64534026e48e1c248991a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_86eee7fa4ae68e4a558dc50961" ON "sso_service_provider" ("createdAt") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_86eee7fa4ae68e4a558dc50961"`);
await queryRunner.query(`DROP TABLE "sso_service_provider"`);
await queryRunner.query(`DROP TYPE "public"."sso_service_provider_binding_enum"`);
await queryRunner.query(`DROP TYPE "public"."sso_service_provider_type_enum"`);
}
}

View File

@ -39,6 +39,12 @@ export class MiSingleSignOnServiceProvider {
})
public audience: string[];
@Column('enum', {
enum: ['post', 'redirect'],
nullable: false,
})
public binding: 'post' | 'redirect';
@Column('varchar', {
length: 512,
})

View File

@ -8,7 +8,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:indie-auth',
res: {

View File

@ -9,7 +9,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:indie-auth',
errors: {

View File

@ -7,7 +7,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'read:admin:indie-auth',
res: {

View File

@ -9,7 +9,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:indie-auth',
errors: {

View File

@ -11,7 +11,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:sso',
errors: {
@ -53,6 +53,11 @@ export const meta = {
optional: false, nullable: false,
items: { type: 'string', nullable: false },
},
binding: {
type: 'string',
optional: false, nullable: false,
enum: ['post', 'redirect'],
},
acsUrl: {
type: 'string',
optional: false, nullable: false,
@ -88,6 +93,7 @@ export const paramDef = {
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
issuer: { type: 'string', nullable: false },
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
binding: { type: 'string', enum: ['post', 'redirect'], nullable: false },
acsUrl: { type: 'string', nullable: false },
signatureAlgorithm: { type: 'string', nullable: false },
cipherAlgorithm: { type: 'string', nullable: true },
@ -126,6 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
type: ps.type,
issuer: ps.issuer,
audience: ps.audience?.filter(i => i.length > 0) ?? [],
binding: ps.binding,
acsUrl: ps.acsUrl,
publicKey: publicKey,
privateKey: privateKey,
@ -147,6 +154,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
type: ssoServiceProvider.type,
issuer: ssoServiceProvider.issuer,
audience: ssoServiceProvider.audience,
binding: ssoServiceProvider.binding,
acsUrl: ssoServiceProvider.acsUrl,
publicKey: ssoServiceProvider.publicKey,
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,

View File

@ -9,7 +9,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:sso',
errors: {

View File

@ -7,7 +7,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'read:admin:sso',
res: {
@ -44,6 +44,11 @@ export const meta = {
optional: false, nullable: false,
items: { type: 'string', nullable: false },
},
binding: {
type: 'string',
optional: false, nullable: false,
enum: ['post', 'redirect'],
},
acsUrl: {
type: 'string',
optional: false, nullable: false,
@ -103,6 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
type: service.type,
issuer: service.issuer,
audience: service.audience,
binding: service.binding,
acsUrl: service.acsUrl,
useCertificate: service.privateKey != null,
publicKey: service.publicKey,

View File

@ -10,7 +10,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'write:admin:sso',
errors: {
@ -29,6 +29,7 @@ export const paramDef = {
name: { type: 'string', nullable: true },
issuer: { type: 'string', nullable: false },
audience: { type: 'array', items: { type: 'string', nullable: false } },
binding: { type: 'string', enum: ['post', 'redirect'], nullable: false },
acsUrl: { type: 'string', nullable: false },
signatureAlgorithm: { type: 'string', nullable: false },
cipherAlgorithm: { type: 'string', nullable: true },
@ -65,6 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
name: ps.name !== '' ? ps.name : null,
issuer: ps.issuer,
audience: ps.audience?.filter(i => i.length > 0),
binding: ps.binding,
acsUrl: ps.acsUrl,
publicKey: publicKey,
privateKey: privateKey,

View File

@ -104,16 +104,10 @@ export class JWTIdentifyProviderService {
});
fastify.post<{
Body: { transaction_id: string; login_token: string; cancel?: string };
Body: { transaction_id: string; login_token: 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) {
@ -190,13 +184,14 @@ export class JWTIdentifyProviderService {
roles: roles.filter(r => r.isPublic).map(r => r.id),
};
let jwt: string;
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)
jwt = await new jose.EncryptJWT(payload)
.setProtectedHeader({
alg: ssoServiceProvider.signatureAlgorithm,
enc: ssoServiceProvider.cipherAlgorithm,
@ -208,31 +203,12 @@ export class JWTIdentifyProviderService {
.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)
jwt = await new jose.SignJWT(payload)
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
.setIssuer(ssoServiceProvider.issuer)
.setAudience(ssoServiceProvider.audience)
@ -241,25 +217,6 @@ export class JWTIdentifyProviderService {
.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 });
@ -289,6 +246,30 @@ export class JWTIdentifyProviderService {
} finally {
await this.redisClient.del(`sso:jwt:transaction:${transactionId}`);
}
this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
reply.header('Cache-Control', 'no-store');
switch (ssoServiceProvider.binding) {
case 'post': return reply
.status(200)
.send({
binding: 'post',
action: ssoServiceProvider.acsUrl,
context: {
jwt,
return_to: returnTo ?? undefined,
},
});
case 'redirect': return reply
.status(200)
.send({
binding: 'redirect',
action: !returnTo
? `${ssoServiceProvider.acsUrl}?jwt=${jwt}`
: `${ssoServiceProvider.acsUrl}?jwt=${jwt}&return_to=${returnTo}`,
});
}
});
}

View File

@ -81,7 +81,7 @@ export class SAMLIdentifyProviderService {
const nodes = {
'md:EntityDescriptor': {
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
'@entityID': provider.issuer,
'@entityID': this.config.url,
'@validUntil': tenYearsLater,
'md:IDPSSODescriptor': {
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
@ -105,6 +105,10 @@ export class SAMLIdentifyProviderService {
'@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}`,
},
],
},
},
@ -185,13 +189,14 @@ export class SAMLIdentifyProviderService {
'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-Redirect',
'md:AssertionConsumerService': {
'@isDefault': 'true',
'@index': 0,
'@Binding': provider.binding === 'post'
? 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
'@Location': provider.acsUrl,
},
],
},
},
};
@ -235,7 +240,7 @@ export class SAMLIdentifyProviderService {
Body?: { SAMLRequest?: string; RelayState?: string };
}>('/:serviceId', async (request, reply) => {
const serviceId = request.params.serviceId;
const binding = 'redirect'; // 今はリダイレクトのみ対応 request.query?.SAMLRequest ? 'redirect' : 'post';
const binding = request.query?.SAMLRequest ? 'redirect' : 'post';
const samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest;
const relayState = request.query?.RelayState ?? request.body?.RelayState;
@ -284,7 +289,6 @@ export class SAMLIdentifyProviderService {
`sso:saml:transaction:${transactionId}`,
JSON.stringify({
serviceId: serviceId,
binding: binding,
flowResult: parsed,
relayState: relayState,
}),
@ -350,16 +354,10 @@ export class SAMLIdentifyProviderService {
);
fastify.post<{
Body: { transaction_id: string; login_token: string; cancel?: string };
Body: { transaction_id: string; login_token: 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) {
@ -374,7 +372,7 @@ export class SAMLIdentifyProviderService {
return;
}
const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction);
const { serviceId, flowResult, relayState } = JSON.parse(transaction);
const ssoServiceProvider =
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
@ -439,7 +437,7 @@ export class SAMLIdentifyProviderService {
const loginResponse = await idp.createLoginResponse(
sp,
flowResult,
binding,
ssoServiceProvider.binding,
{},
() => {
const id = idp.entitySetting.generateID?.() ?? randomUUID();
@ -655,16 +653,27 @@ export class SAMLIdentifyProviderService {
relayState,
);
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
userId: user.id,
ssoServiceProvider: ssoServiceProvider.id,
acsUrl: ssoServiceProvider.acsUrl,
relayState: relayState,
this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
reply.header('Cache-Control', 'no-store');
switch (ssoServiceProvider.binding) {
case 'post': return reply
.status(200)
.send({
binding: 'post',
action: ssoServiceProvider.acsUrl,
context: {
SAMLResponse: loginResponse.context,
RelayState: relayState ?? undefined,
},
});
reply.header('Cache-Control', 'no-store');
reply.redirect(loginResponse.context);
return;
case 'redirect': return reply
.status(200)
.send({
binding: 'redirect',
action: loginResponse.context,
});
}
} catch (err) {
this.#logger.error('Failed to create SAML response', { error: err });
const traceableError = err as Error & { code?: string };

View File

@ -187,7 +187,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="service.name">
<template #label>Name</template>
</MkInput>
<MkRadios v-model="service.type">
<MkRadios v-model="service.type" :disabled="!!service.createdAt">
<option value="jwt">JWT</option>
<option value="saml">SAML</option>
</MkRadios>
@ -197,6 +197,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="service.audience">
<template #label>Audience</template>
</MkTextarea>
<MkRadios v-model="service.binding">
<option value="post">POST</option>
<option value="redirect">Redirect</option>
</MkRadios>
<MkInput v-model="service.acsUrl">
<template #label>Assertion Consumer Service URL</template>
</MkInput>
@ -426,6 +430,7 @@ function ssoServiceAddNew() {
type: 'jwt',
issuer: '',
audience: '',
binding: 'post',
acsUrl: '',
useCertificate: false,
publicKey: '',
@ -457,6 +462,7 @@ async function ssoServiceSave(service) {
type: service.type,
issuer: service.issuer,
audience: service.audience.split('\n'),
binding: service.binding,
acsUrl: service.acsUrl,
secret: service.publicKey,
signatureAlgorithm: service.signatureAlgorithm,

View File

@ -7,16 +7,23 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<MkSpacer :contentMax="800">
<div v-if="$i">
<div v-if="$i && !loading">
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
<form :class="$style.buttons" :action="`/sso/${kind}/authorize`" accept-charset="utf-8" method="post">
<input name="transaction_id" class="mk-input-tr-id-hidden" type="hidden" :value="transactionIdMeta?.content"/>
<input name="login_token" class="mk-input-token-hidden" type="hidden" :value="$i.token"/>
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
<div :class="$style.buttons">
<MkButton @click="onCancel">{{ i18n.ts.cancel }}</MkButton>
<MkButton primary @click="onAccept">{{ i18n.ts.accept }}</MkButton>
</div>
</div>
<div v-else-if="$i && loading">
<div>{{ i18n.ts._auth.callback }}</div>
<MkLoading class="loading"/>
<div style="display: none">
<form ref="postBindingForm" method="post" :action="actionUrl" autocomplete="off">
<input v-for="(value, key) in actionContext" :key="key" :name="key" :value="value" type="hidden"/>
</form>
</div>
</div>
<div v-else>
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
<MkSignin @login="onLogin"/>
@ -26,24 +33,63 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, nextTick } from 'vue';
import MkSignin from '@/components/MkSignin.vue';
import MkButton from '@/components/MkButton.vue';
import { $i, login } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:transaction-id"]');
if (transactionIdMeta) {
transactionIdMeta.remove();
}
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:service-name"]')?.content;
const kind = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:kind"]')?.content;
const loading = ref(false);
const postBindingForm = ref<HTMLFormElement | null>(null);
const actionUrl = ref<string | undefined>(undefined);
const actionContext = ref<Record<string, string> | null>(null);
function onLogin(res): void {
login(res.i);
}
function onCancel(): void {
if (history.length > 1) history.back();
else location.href = '/';
}
function onAccept(): void {
loading.value = true;
os.promiseDialog(authorize());
}
async function authorize(): Promise<void> {
const res = await fetch(`/sso/${kind}/authorize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
transaction_id: transactionIdMeta?.content,
login_token: $i!.token,
}),
});
const json = await res.json();
if (json.binding === 'post') {
actionUrl.value = json.action;
actionContext.value = json.context;
nextTick(() => {
postBindingForm.value?.submit();
});
} else {
location.href = json.action;
}
}
definePageMetadata(() => ({
title: 'Single Sign-On',
icon: 'ti ti-apps',

View File

@ -10339,6 +10339,8 @@ export type operations = {
issuer: string;
/** @default [] */
audience?: string[];
/** @enum {string} */
binding?: 'post' | 'redirect';
acsUrl: string;
signatureAlgorithm: string;
cipherAlgorithm?: string | null;
@ -10365,6 +10367,8 @@ export type operations = {
type: 'saml' | 'jwt';
issuer: string;
audience: string[];
/** @enum {string} */
binding: 'post' | 'redirect';
acsUrl: string;
publicKey: string;
signatureAlgorithm: string;
@ -10487,6 +10491,8 @@ export type operations = {
type: 'saml' | 'jwt';
issuer: string;
audience: string[];
/** @enum {string} */
binding: 'post' | 'redirect';
acsUrl: string;
useCertificate: boolean;
publicKey: string;
@ -10543,6 +10549,8 @@ export type operations = {
name?: string | null;
issuer?: string;
audience?: string[];
/** @enum {string} */
binding?: 'post' | 'redirect';
acsUrl?: string;
signatureAlgorithm?: string;
cipherAlgorithm?: string | null;