enhance(SSO): SAML認証でHTTP-POSTバインディングに対応 (MisskeyIO#531)
This commit is contained in:
parent
27c897d19f
commit
aebe9ae148
|
@ -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"`);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,6 +39,12 @@ export class MiSingleSignOnServiceProvider {
|
||||||
})
|
})
|
||||||
public audience: string[];
|
public audience: string[];
|
||||||
|
|
||||||
|
@Column('enum', {
|
||||||
|
enum: ['post', 'redirect'],
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
public binding: 'post' | 'redirect';
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 512,
|
length: 512,
|
||||||
})
|
})
|
||||||
|
|
|
@ -8,7 +8,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:indie-auth',
|
kind: 'write:admin:indie-auth',
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:indie-auth',
|
kind: 'write:admin:indie-auth',
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'read:admin:indie-auth',
|
kind: 'read:admin:indie-auth',
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:indie-auth',
|
kind: 'write:admin:indie-auth',
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
|
|
|
@ -11,7 +11,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:sso',
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
|
@ -53,6 +53,11 @@ export const meta = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
items: { type: 'string', nullable: false },
|
items: { type: 'string', nullable: false },
|
||||||
},
|
},
|
||||||
|
binding: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['post', 'redirect'],
|
||||||
|
},
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -88,6 +93,7 @@ export const paramDef = {
|
||||||
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
|
type: { type: 'string', enum: ['saml', 'jwt'], nullable: false },
|
||||||
issuer: { type: 'string', nullable: false },
|
issuer: { type: 'string', nullable: false },
|
||||||
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
|
audience: { type: 'array', items: { type: 'string', nullable: false }, default: [] },
|
||||||
|
binding: { type: 'string', enum: ['post', 'redirect'], nullable: false },
|
||||||
acsUrl: { type: 'string', nullable: false },
|
acsUrl: { type: 'string', nullable: false },
|
||||||
signatureAlgorithm: { type: 'string', nullable: false },
|
signatureAlgorithm: { type: 'string', nullable: false },
|
||||||
cipherAlgorithm: { type: 'string', nullable: true },
|
cipherAlgorithm: { type: 'string', nullable: true },
|
||||||
|
@ -126,6 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
type: ps.type,
|
type: ps.type,
|
||||||
issuer: ps.issuer,
|
issuer: ps.issuer,
|
||||||
audience: ps.audience?.filter(i => i.length > 0) ?? [],
|
audience: ps.audience?.filter(i => i.length > 0) ?? [],
|
||||||
|
binding: ps.binding,
|
||||||
acsUrl: ps.acsUrl,
|
acsUrl: ps.acsUrl,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
privateKey: privateKey,
|
privateKey: privateKey,
|
||||||
|
@ -147,6 +154,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
type: ssoServiceProvider.type,
|
type: ssoServiceProvider.type,
|
||||||
issuer: ssoServiceProvider.issuer,
|
issuer: ssoServiceProvider.issuer,
|
||||||
audience: ssoServiceProvider.audience,
|
audience: ssoServiceProvider.audience,
|
||||||
|
binding: ssoServiceProvider.binding,
|
||||||
acsUrl: ssoServiceProvider.acsUrl,
|
acsUrl: ssoServiceProvider.acsUrl,
|
||||||
publicKey: ssoServiceProvider.publicKey,
|
publicKey: ssoServiceProvider.publicKey,
|
||||||
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
|
signatureAlgorithm: ssoServiceProvider.signatureAlgorithm,
|
||||||
|
|
|
@ -9,7 +9,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:sso',
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'read:admin:sso',
|
kind: 'read:admin:sso',
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
|
@ -44,6 +44,11 @@ export const meta = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
items: { type: 'string', nullable: false },
|
items: { type: 'string', nullable: false },
|
||||||
},
|
},
|
||||||
|
binding: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
enum: ['post', 'redirect'],
|
||||||
|
},
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
@ -103,6 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
type: service.type,
|
type: service.type,
|
||||||
issuer: service.issuer,
|
issuer: service.issuer,
|
||||||
audience: service.audience,
|
audience: service.audience,
|
||||||
|
binding: service.binding,
|
||||||
acsUrl: service.acsUrl,
|
acsUrl: service.acsUrl,
|
||||||
useCertificate: service.privateKey != null,
|
useCertificate: service.privateKey != null,
|
||||||
publicKey: service.publicKey,
|
publicKey: service.publicKey,
|
||||||
|
|
|
@ -10,7 +10,7 @@ export const meta = {
|
||||||
tags: ['admin'],
|
tags: ['admin'],
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireAdmin: true,
|
||||||
kind: 'write:admin:sso',
|
kind: 'write:admin:sso',
|
||||||
|
|
||||||
errors: {
|
errors: {
|
||||||
|
@ -29,6 +29,7 @@ export const paramDef = {
|
||||||
name: { type: 'string', nullable: true },
|
name: { type: 'string', nullable: true },
|
||||||
issuer: { type: 'string', nullable: false },
|
issuer: { type: 'string', nullable: false },
|
||||||
audience: { type: 'array', items: { 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 },
|
acsUrl: { type: 'string', nullable: false },
|
||||||
signatureAlgorithm: { type: 'string', nullable: false },
|
signatureAlgorithm: { type: 'string', nullable: false },
|
||||||
cipherAlgorithm: { type: 'string', nullable: true },
|
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,
|
name: ps.name !== '' ? ps.name : null,
|
||||||
issuer: ps.issuer,
|
issuer: ps.issuer,
|
||||||
audience: ps.audience?.filter(i => i.length > 0),
|
audience: ps.audience?.filter(i => i.length > 0),
|
||||||
|
binding: ps.binding,
|
||||||
acsUrl: ps.acsUrl,
|
acsUrl: ps.acsUrl,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
privateKey: privateKey,
|
privateKey: privateKey,
|
||||||
|
|
|
@ -104,16 +104,10 @@ export class JWTIdentifyProviderService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Body: { transaction_id: string; login_token: string; cancel?: string };
|
Body: { transaction_id: string; login_token: string; };
|
||||||
}>('/authorize', async (request, reply) => {
|
}>('/authorize', async (request, reply) => {
|
||||||
const transactionId = request.body.transaction_id;
|
const transactionId = request.body.transaction_id;
|
||||||
const token = request.body.login_token;
|
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}`);
|
const transaction = await this.redisClient.get(`sso:jwt:transaction:${transactionId}`);
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
|
@ -190,13 +184,14 @@ export class JWTIdentifyProviderService {
|
||||||
roles: roles.filter(r => r.isPublic).map(r => r.id),
|
roles: roles.filter(r => r.isPublic).map(r => r.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let jwt: string;
|
||||||
try {
|
try {
|
||||||
if (ssoServiceProvider.cipherAlgorithm) {
|
if (ssoServiceProvider.cipherAlgorithm) {
|
||||||
const key = ssoServiceProvider.publicKey.startsWith('{')
|
const key = ssoServiceProvider.publicKey.startsWith('{')
|
||||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.publicKey))
|
||||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
const jwt = await new jose.EncryptJWT(payload)
|
jwt = await new jose.EncryptJWT(payload)
|
||||||
.setProtectedHeader({
|
.setProtectedHeader({
|
||||||
alg: ssoServiceProvider.signatureAlgorithm,
|
alg: ssoServiceProvider.signatureAlgorithm,
|
||||||
enc: ssoServiceProvider.cipherAlgorithm,
|
enc: ssoServiceProvider.cipherAlgorithm,
|
||||||
|
@ -208,31 +203,12 @@ export class JWTIdentifyProviderService {
|
||||||
.setJti(randomUUID())
|
.setJti(randomUUID())
|
||||||
.setSubject(user.id)
|
.setSubject(user.id)
|
||||||
.encrypt(key);
|
.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 {
|
} else {
|
||||||
const key = ssoServiceProvider.privateKey
|
const key = ssoServiceProvider.privateKey
|
||||||
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
? await jose.importJWK(JSON.parse(ssoServiceProvider.privateKey))
|
||||||
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
: jose.base64url.decode(ssoServiceProvider.publicKey);
|
||||||
|
|
||||||
const jwt = await new jose.SignJWT(payload)
|
jwt = await new jose.SignJWT(payload)
|
||||||
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
|
.setProtectedHeader({ alg: ssoServiceProvider.signatureAlgorithm })
|
||||||
.setIssuer(ssoServiceProvider.issuer)
|
.setIssuer(ssoServiceProvider.issuer)
|
||||||
.setAudience(ssoServiceProvider.audience)
|
.setAudience(ssoServiceProvider.audience)
|
||||||
|
@ -241,25 +217,6 @@ export class JWTIdentifyProviderService {
|
||||||
.setJti(randomUUID())
|
.setJti(randomUUID())
|
||||||
.setSubject(user.id)
|
.setSubject(user.id)
|
||||||
.sign(key);
|
.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) {
|
} catch (err) {
|
||||||
this.#logger.error('Failed to create JWT', { error: err });
|
this.#logger.error('Failed to create JWT', { error: err });
|
||||||
|
@ -289,6 +246,30 @@ export class JWTIdentifyProviderService {
|
||||||
} finally {
|
} finally {
|
||||||
await this.redisClient.del(`sso:jwt:transaction:${transactionId}`);
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -81,7 +81,7 @@ export class SAMLIdentifyProviderService {
|
||||||
const nodes = {
|
const nodes = {
|
||||||
'md:EntityDescriptor': {
|
'md:EntityDescriptor': {
|
||||||
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
'@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata',
|
||||||
'@entityID': provider.issuer,
|
'@entityID': this.config.url,
|
||||||
'@validUntil': tenYearsLater,
|
'@validUntil': tenYearsLater,
|
||||||
'md:IDPSSODescriptor': {
|
'md:IDPSSODescriptor': {
|
||||||
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
'@WantAuthnRequestsSigned': provider.wantAuthnRequestsSigned,
|
||||||
|
@ -105,6 +105,10 @@ export class SAMLIdentifyProviderService {
|
||||||
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||||
'@Location': `${this.config.url}/sso/saml/${provider.id}`,
|
'@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': {
|
'md:NameIDFormat': {
|
||||||
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
'#text': 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||||
},
|
},
|
||||||
'md:AssertionConsumerService': [
|
'md:AssertionConsumerService': {
|
||||||
{
|
'@isDefault': 'true',
|
||||||
'@index': 1,
|
'@index': 0,
|
||||||
'@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
'@Binding': provider.binding === 'post'
|
||||||
'@Location': provider.acsUrl,
|
? '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 };
|
Body?: { SAMLRequest?: string; RelayState?: string };
|
||||||
}>('/:serviceId', async (request, reply) => {
|
}>('/:serviceId', async (request, reply) => {
|
||||||
const serviceId = request.params.serviceId;
|
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 samlRequest = request.query?.SAMLRequest ?? request.body?.SAMLRequest;
|
||||||
const relayState = request.query?.RelayState ?? request.body?.RelayState;
|
const relayState = request.query?.RelayState ?? request.body?.RelayState;
|
||||||
|
|
||||||
|
@ -284,7 +289,6 @@ export class SAMLIdentifyProviderService {
|
||||||
`sso:saml:transaction:${transactionId}`,
|
`sso:saml:transaction:${transactionId}`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
serviceId: serviceId,
|
serviceId: serviceId,
|
||||||
binding: binding,
|
|
||||||
flowResult: parsed,
|
flowResult: parsed,
|
||||||
relayState: relayState,
|
relayState: relayState,
|
||||||
}),
|
}),
|
||||||
|
@ -350,16 +354,10 @@ export class SAMLIdentifyProviderService {
|
||||||
);
|
);
|
||||||
|
|
||||||
fastify.post<{
|
fastify.post<{
|
||||||
Body: { transaction_id: string; login_token: string; cancel?: string };
|
Body: { transaction_id: string; login_token: string; };
|
||||||
}>('/authorize', async (request, reply) => {
|
}>('/authorize', async (request, reply) => {
|
||||||
const transactionId = request.body.transaction_id;
|
const transactionId = request.body.transaction_id;
|
||||||
const token = request.body.login_token;
|
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}`);
|
const transaction = await this.redisClient.get(`sso:saml:transaction:${transactionId}`);
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
|
@ -374,7 +372,7 @@ export class SAMLIdentifyProviderService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serviceId, binding, flowResult, relayState } = JSON.parse(transaction);
|
const { serviceId, flowResult, relayState } = JSON.parse(transaction);
|
||||||
|
|
||||||
const ssoServiceProvider =
|
const ssoServiceProvider =
|
||||||
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
await this.singleSignOnServiceProviderRepository.findOneBy({ id: serviceId, type: 'saml' });
|
||||||
|
@ -439,7 +437,7 @@ export class SAMLIdentifyProviderService {
|
||||||
const loginResponse = await idp.createLoginResponse(
|
const loginResponse = await idp.createLoginResponse(
|
||||||
sp,
|
sp,
|
||||||
flowResult,
|
flowResult,
|
||||||
binding,
|
ssoServiceProvider.binding,
|
||||||
{},
|
{},
|
||||||
() => {
|
() => {
|
||||||
const id = idp.entitySetting.generateID?.() ?? randomUUID();
|
const id = idp.entitySetting.generateID?.() ?? randomUUID();
|
||||||
|
@ -655,16 +653,27 @@ export class SAMLIdentifyProviderService {
|
||||||
relayState,
|
relayState,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#logger.info(`Redirecting to "${ssoServiceProvider.acsUrl}"`, {
|
this.#logger.info(`User "${user.username}" authorized for "${ssoServiceProvider.name ?? ssoServiceProvider.issuer}"`);
|
||||||
userId: user.id,
|
|
||||||
ssoServiceProvider: ssoServiceProvider.id,
|
|
||||||
acsUrl: ssoServiceProvider.acsUrl,
|
|
||||||
relayState: relayState,
|
|
||||||
});
|
|
||||||
|
|
||||||
reply.header('Cache-Control', 'no-store');
|
reply.header('Cache-Control', 'no-store');
|
||||||
reply.redirect(loginResponse.context);
|
switch (ssoServiceProvider.binding) {
|
||||||
return;
|
case 'post': return reply
|
||||||
|
.status(200)
|
||||||
|
.send({
|
||||||
|
binding: 'post',
|
||||||
|
action: ssoServiceProvider.acsUrl,
|
||||||
|
context: {
|
||||||
|
SAMLResponse: loginResponse.context,
|
||||||
|
RelayState: relayState ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'redirect': return reply
|
||||||
|
.status(200)
|
||||||
|
.send({
|
||||||
|
binding: 'redirect',
|
||||||
|
action: loginResponse.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.#logger.error('Failed to create SAML response', { error: err });
|
this.#logger.error('Failed to create SAML response', { error: err });
|
||||||
const traceableError = err as Error & { code?: string };
|
const traceableError = err as Error & { code?: string };
|
||||||
|
|
|
@ -187,7 +187,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkInput v-model="service.name">
|
<MkInput v-model="service.name">
|
||||||
<template #label>Name</template>
|
<template #label>Name</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
<MkRadios v-model="service.type">
|
<MkRadios v-model="service.type" :disabled="!!service.createdAt">
|
||||||
<option value="jwt">JWT</option>
|
<option value="jwt">JWT</option>
|
||||||
<option value="saml">SAML</option>
|
<option value="saml">SAML</option>
|
||||||
</MkRadios>
|
</MkRadios>
|
||||||
|
@ -197,6 +197,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkTextarea v-model="service.audience">
|
<MkTextarea v-model="service.audience">
|
||||||
<template #label>Audience</template>
|
<template #label>Audience</template>
|
||||||
</MkTextarea>
|
</MkTextarea>
|
||||||
|
<MkRadios v-model="service.binding">
|
||||||
|
<option value="post">POST</option>
|
||||||
|
<option value="redirect">Redirect</option>
|
||||||
|
</MkRadios>
|
||||||
<MkInput v-model="service.acsUrl">
|
<MkInput v-model="service.acsUrl">
|
||||||
<template #label>Assertion Consumer Service URL</template>
|
<template #label>Assertion Consumer Service URL</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
@ -426,6 +430,7 @@ function ssoServiceAddNew() {
|
||||||
type: 'jwt',
|
type: 'jwt',
|
||||||
issuer: '',
|
issuer: '',
|
||||||
audience: '',
|
audience: '',
|
||||||
|
binding: 'post',
|
||||||
acsUrl: '',
|
acsUrl: '',
|
||||||
useCertificate: false,
|
useCertificate: false,
|
||||||
publicKey: '',
|
publicKey: '',
|
||||||
|
@ -457,6 +462,7 @@ async function ssoServiceSave(service) {
|
||||||
type: service.type,
|
type: service.type,
|
||||||
issuer: service.issuer,
|
issuer: service.issuer,
|
||||||
audience: service.audience.split('\n'),
|
audience: service.audience.split('\n'),
|
||||||
|
binding: service.binding,
|
||||||
acsUrl: service.acsUrl,
|
acsUrl: service.acsUrl,
|
||||||
secret: service.publicKey,
|
secret: service.publicKey,
|
||||||
signatureAlgorithm: service.signatureAlgorithm,
|
signatureAlgorithm: service.signatureAlgorithm,
|
||||||
|
|
|
@ -7,15 +7,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header><MkPageHeader/></template>
|
<template #header><MkPageHeader/></template>
|
||||||
<MkSpacer :contentMax="800">
|
<MkSpacer :contentMax="800">
|
||||||
<div v-if="$i">
|
<div v-if="$i && !loading">
|
||||||
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
|
<div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div>
|
||||||
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
|
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
|
||||||
<form :class="$style.buttons" :action="`/sso/${kind}/authorize`" accept-charset="utf-8" method="post">
|
<div :class="$style.buttons">
|
||||||
<input name="transaction_id" class="mk-input-tr-id-hidden" type="hidden" :value="transactionIdMeta?.content"/>
|
<MkButton @click="onCancel">{{ i18n.ts.cancel }}</MkButton>
|
||||||
<input name="login_token" class="mk-input-token-hidden" type="hidden" :value="$i.token"/>
|
<MkButton primary @click="onAccept">{{ i18n.ts.accept }}</MkButton>
|
||||||
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
|
</div>
|
||||||
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
|
</div>
|
||||||
</form>
|
<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>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
|
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
|
||||||
|
@ -26,24 +33,63 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { ref, nextTick } from 'vue';
|
||||||
import MkSignin from '@/components/MkSignin.vue';
|
import MkSignin from '@/components/MkSignin.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { $i, login } from '@/account.js';
|
import { $i, login } from '@/account.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:transaction-id"]');
|
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:transaction-id"]');
|
||||||
if (transactionIdMeta) {
|
if (transactionIdMeta) {
|
||||||
transactionIdMeta.remove();
|
transactionIdMeta.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:service-name"]')?.content;
|
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:service-name"]')?.content;
|
||||||
const kind = document.querySelector<HTMLMetaElement>('meta[name="misskey:sso:kind"]')?.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 {
|
function onLogin(res): void {
|
||||||
login(res.i);
|
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(() => ({
|
definePageMetadata(() => ({
|
||||||
title: 'Single Sign-On',
|
title: 'Single Sign-On',
|
||||||
icon: 'ti ti-apps',
|
icon: 'ti ti-apps',
|
||||||
|
|
|
@ -10339,6 +10339,8 @@ export type operations = {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
/** @default [] */
|
/** @default [] */
|
||||||
audience?: string[];
|
audience?: string[];
|
||||||
|
/** @enum {string} */
|
||||||
|
binding?: 'post' | 'redirect';
|
||||||
acsUrl: string;
|
acsUrl: string;
|
||||||
signatureAlgorithm: string;
|
signatureAlgorithm: string;
|
||||||
cipherAlgorithm?: string | null;
|
cipherAlgorithm?: string | null;
|
||||||
|
@ -10365,6 +10367,8 @@ export type operations = {
|
||||||
type: 'saml' | 'jwt';
|
type: 'saml' | 'jwt';
|
||||||
issuer: string;
|
issuer: string;
|
||||||
audience: string[];
|
audience: string[];
|
||||||
|
/** @enum {string} */
|
||||||
|
binding: 'post' | 'redirect';
|
||||||
acsUrl: string;
|
acsUrl: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
signatureAlgorithm: string;
|
signatureAlgorithm: string;
|
||||||
|
@ -10487,6 +10491,8 @@ export type operations = {
|
||||||
type: 'saml' | 'jwt';
|
type: 'saml' | 'jwt';
|
||||||
issuer: string;
|
issuer: string;
|
||||||
audience: string[];
|
audience: string[];
|
||||||
|
/** @enum {string} */
|
||||||
|
binding: 'post' | 'redirect';
|
||||||
acsUrl: string;
|
acsUrl: string;
|
||||||
useCertificate: boolean;
|
useCertificate: boolean;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
|
@ -10543,6 +10549,8 @@ export type operations = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
audience?: string[];
|
audience?: string[];
|
||||||
|
/** @enum {string} */
|
||||||
|
binding?: 'post' | 'redirect';
|
||||||
acsUrl?: string;
|
acsUrl?: string;
|
||||||
signatureAlgorithm?: string;
|
signatureAlgorithm?: string;
|
||||||
cipherAlgorithm?: string | null;
|
cipherAlgorithm?: string | null;
|
||||||
|
|
Loading…
Reference in New Issue